diff --git a/packages/docs/docs/sdk/oauth-client-node.md b/packages/docs/docs/sdk/oauth-client-node.md index 4c6c84d..25d9100 100644 --- a/packages/docs/docs/sdk/oauth-client-node.md +++ b/packages/docs/docs/sdk/oauth-client-node.md @@ -1,6 +1,6 @@ # Node Client -The Node client handles OAuth authorization and callback flows for server-side Node.js apps authenticating with a HappyView instance. It wraps the [OAuth Client](./oauth-client.md) with handle/DID resolution and a server-friendly API that returns authorization URLs instead of redirecting the browser. +Server-side OAuth client for authenticating with a HappyView instance using AT Protocol. Built on top of [`@happyview/oauth-client`](./oauth-client.md), matching the API surface of [`@atproto/oauth-client-node`](https://www.npmjs.com/package/@atproto/oauth-client-node). ## Installation @@ -17,107 +17,133 @@ const client = new HappyViewNodeClient({ instanceUrl: "https://happyview.example.com", clientId: "https://example.com/oauth-client-metadata.json", clientKey: "hvc_your_client_key", - clientSecret: "hvs_your_secret", // optional, for confidential clients redirectUri: "https://example.com/oauth/callback", storage: myStorageAdapter, }); ``` -| Option | Required | Description | -| ------------- | -------- | ---------------------------------------------------------------------------- | -| `instanceUrl` | Yes | The HappyView instance URL | -| `clientId` | Yes | URL where your app serves its [OAuth client metadata](#oauth-client-metadata) | -| `clientKey` | Yes | API client key from the HappyView admin dashboard | -| `clientSecret`| No | API client secret — makes this a confidential client | -| `redirectUri` | Yes | OAuth callback URL for your server | -| `scopes` | No | OAuth scopes to request. Defaults to `"atproto"` | -| `storage` | Yes | Storage adapter for persisting sessions and pending auth state | -| `sessionHooks`| No | Hooks called on session lifecycle events | -| `fetch` | No | Custom fetch implementation | +| Option | Required | Description | +| -------------- | -------- | ----------------------------------------------------------------------------- | +| `instanceUrl` | Yes | The HappyView instance URL | +| `clientId` | Yes | URL where your app serves its [OAuth client metadata](#oauth-client-metadata) | +| `clientKey` | Yes | API client key from the HappyView admin dashboard | +| `redirectUri` | Yes | OAuth callback URL | +| `storage` | Yes | Storage adapter for persisting sessions and auth state | +| `clientSecret` | No | Secret for confidential clients | +| `scopes` | No | OAuth scopes to request. Defaults to `"atproto"` | +| `sessionHooks` | No | Event hooks for session lifecycle events | +| `fetch` | No | Custom fetch implementation | -:::note -Unlike the browser client, `storage` is required — there is no default. Use any adapter that implements `StorageAdapter` (e.g., backed by Redis, a database, or the filesystem). -::: +### Storage -## Authorization +You must provide a `StorageAdapter`. The built-in `MemoryStorage` works for development but won't survive restarts: -`authorize()` resolves the user's handle, discovers their PDS, provisions a DPoP key, and returns an authorization URL. Your server should redirect the user to this URL: +```typescript +import { MemoryStorage } from "@happyview/oauth-client-node"; + +const client = new HappyViewNodeClient({ + // ... + storage: new MemoryStorage(), +}); +``` + +For production, implement the `StorageAdapter` interface backed by your database or cache: ```typescript -// In your login route handler -const url = await client.authorize("alice.bsky.social"); -res.redirect(url.toString()); +interface StorageAdapter { + get(key: string): Promise; + set(key: string, value: string): Promise; + delete(key: string): Promise; +} ``` -### Authorization options +## Authorize + +Generate an authorization URL and redirect the user: + +```typescript +const url = await client.authorize("alice.bsky.social"); +res.redirect(url.href); +``` -Pass options to customize the authorization request: +Options: ```typescript const url = await client.authorize("alice.bsky.social", { scope: "atproto transition:generic", - state: myCustomState, redirect_uri: "https://example.com/alt-callback", - display: "popup", - prompt: "consent", - ui_locales: "en", + state: "my-custom-state", + prompt: "login", + display: "page", }); ``` -| Option | Description | -| ------------- | ------------------------------------------------------------ | -| `scope` | Override the default scopes for this request | -| `state` | Custom state value (defaults to a random hex string) | -| `redirect_uri`| Override the default redirect URI for this request | -| `display` | `"page"`, `"popup"`, `"touch"`, or `"wap"` | -| `prompt` | Prompt behavior (e.g., `"consent"`, `"login"`) | -| `nonce` | Nonce for ID token validation | -| `max_age` | Maximum authentication age in seconds | -| `ui_locales` | Preferred UI languages | -| `signal` | AbortSignal to cancel the request | +| Option | Description | +| ------------------------ | ---------------------------------------------------------------------------- | +| `scope` | OAuth scopes for this request (overrides constructor default) | +| `scopes` | Deprecated alias for `scope`. `scope` takes priority if both passed. | +| `state` | Custom state value. Defaults to a random hex string. | +| `redirect_uri` | Override the redirect URI for this request | +| `signal` | `AbortSignal` for cancellation | +| `display` | Display hint: `"page"`, `"popup"`, `"touch"`, or `"wap"` | +| `prompt` | Prompt mode (e.g. `"login"` to force re-authentication) | +| `nonce` | OIDC nonce value | +| `max_age` | Max elapsed seconds since last active authentication | +| `ui_locales` | Space-separated locale tags (e.g. `"en fr"`) | +| `dpop_jkt` | DPoP JWK thumbprint | +| `claims` | OIDC claims request object | +| `authorization_details` | RFC 9396 authorization details | +| `id_token_hint` | Previous ID token hint | + +### Abort a pending request + +If you need to cancel a pending authorization (e.g., the user navigates away), pass the URL returned from `authorize()`: + +```typescript +const url = await client.authorize("alice.bsky.social"); +// ...later, if the user cancels: +await client.abortRequest(url); +``` + +This cleans up the stored pending auth state. ## Callback -In your OAuth callback route, pass the query parameters to `callback()`: +On your callback route, process the OAuth response: ```typescript -// In your callback route handler -const params = new URLSearchParams(req.url.split("?")[1]); -const { session, state } = await client.callback(params); +app.get("/oauth/callback", async (req, res) => { + const params = new URLSearchParams(req.url.split("?")[1]); + const { session, state } = await client.callback(params); -// session is ready to use -console.log(session.did); -console.log(session.scopes); + // session.did is the authenticated user's DID + // state is the value passed to authorize() (or the auto-generated one) +}); ``` -The returned `HappyViewSession` is persisted to storage and ready for authenticated requests. - -You can override the redirect URI if needed: +You can override the redirect URI for this specific callback: ```typescript const { session } = await client.callback(params, { - redirect_uri: "https://example.com/alt-callback", + redirect_uri: "https://other.example.com/callback", }); ``` -## Checking approved scopes +## Restore session -After authorization or session restoration, you can check which scopes were approved: +Restore a session by DID: ```typescript -console.log(session.scopes); -// ["atproto", "transition:generic"] +const session = await client.restore("did:plc:abc123"); ``` -To fetch the latest scopes from the server: +The `did` parameter is required in the node client (unlike the browser client, there's no "last active" session concept on the server). -```typescript -const info = await client.getSession("did:plc:abc123"); -console.log(info.scopes); -// ["atproto", "transition:generic"] -``` +A second `refresh` parameter is accepted for API compatibility with upstream (`restore(did, refresh?)`). HappyView manages token refresh server-side, so this parameter is accepted but ignored. + +## Session -## Authenticated requests +### Authenticated requests The session's `fetchHandler` attaches DPoP proof headers automatically: @@ -132,43 +158,149 @@ const data = await response.json(); Pass a relative path (prepends the HappyView instance URL) or a full URL (used as-is). -## Session restoration +### Token info + +```typescript +const info = session.getTokenInfo(); +// { sub, scope, iss, aud, expiresAt?, expired? } +``` + +Returns available metadata about the session. `expiresAt` and `expired` are always `undefined` since HappyView manages token lifecycle server-side. + +### Properties -Restore a previously stored session by DID: +| Property | Type | Description | +| -------- | -------- | ---------------------------------------- | +| `did` | `string` | The authenticated user's DID | +| `sub` | `string` | Alias for `did` (matches upstream naming) | + +### Sign out + +Sessions can self-revoke: ```typescript -const session = await client.restore("did:plc:abc123"); +await session.signOut(); +``` + +This is equivalent to calling `client.revoke(session.did)`. + +## Confidential vs public clients + +Clients created with a `clientSecret` are confidential — they can hold secrets safely on the server. Clients without a secret are public. Use `client.isConfidential` to check: + +```typescript +const client = new HappyViewNodeClient({ + // ... + clientSecret: "hvs_your_secret", +}); +client.isConfidential; // true ``` -Unlike the browser client, `restore()` requires a DID — there is no "last active" session concept on the server. +Public clients use PKCE to secure the DPoP key provisioning step. Confidential clients authenticate with their secret instead. -Throws `InvalidStateError` if no session is found for the given DID. +## Session event hooks + +React to session lifecycle events with `sessionHooks`: + +```typescript +const client = new HappyViewNodeClient({ + // ... + sessionHooks: { + onSessionUpdate(did) { + console.log(`Session created/updated for ${did}`); + }, + onSessionDelete(did) { + console.log(`Session deleted for ${did}`); + }, + }, +}); +``` + +- `onSessionUpdate(did)` fires after a session is registered (from `callback()`) or restored. +- `onSessionDelete(did)` fires after a session is revoked (from `revoke()` or `session.signOut()`). + +## Error handling + +Callback errors are always wrapped in `OAuthCallbackError`, which carries the original callback params and state: + +```typescript +import { OAuthCallbackError } from "@happyview/oauth-client-node"; + +try { + const { session } = await client.callback(params); +} catch (err) { + if (err instanceof OAuthCallbackError) { + console.log(err.state); // the state from the callback + console.log(err.params.get("error")); // e.g. "access_denied" + console.log(err.cause); // the underlying error, if any + } +} +``` + +If the authorization server returns an error (e.g., the user denied access), the `params` contain the `error` and `error_description` fields from the server response. If the token exchange fails, the underlying `TokenExchangeError` is available as `err.cause`. + +## Using with @atproto/api + +`HappyViewSession` is directly compatible with `@atproto/api`'s `Agent`: + +```typescript +import { Agent } from "@atproto/api"; + +const session = await client.restore("did:plc:abc123"); +const agent = new Agent(session); + +const profile = await agent.getProfile({ actor: agent.did }); +await agent.like(postUri, postCid); +``` + +This works because `HappyViewSession` implements the `SessionManager` interface that `Agent` expects. ## Revoke session +From the client: + ```typescript await client.revoke("did:plc:abc123"); ``` -## Aborting a pending authorization +Or from the session itself: -If the user abandons the login flow, clean up the pending state: +```typescript +await session.signOut(); +``` + +## Validate client metadata + +Verify that your OAuth client metadata is served correctly: ```typescript -await client.abortRequest(authorizationUrl); +const metadata = await HappyViewNodeClient.fetchMetadata({ + clientId: "https://example.com/oauth-client-metadata.json", +}); +console.log(metadata.client_name); +``` + +## Identity resolution + +The client exposes its handle and DID resolvers for advanced use: + +```typescript +const did = await client.handleResolver.resolve("alice.bsky.social"); +const doc = await client.didResolver.resolve(did); ``` ## OAuth client metadata -Your app must serve an OAuth client metadata JSON document at the URL you pass as `clientId`. The PDS fetches this during authorization to validate the redirect URI and display your app's information. +Your app must serve an OAuth client metadata JSON document at the URL you pass as `clientId`. The PDS fetches this during authorization. + +For a confidential Node.js server: ```typescript -// Express example app.get("/oauth-client-metadata.json", (req, res) => { const origin = `${req.protocol}://${req.get("host")}`; res.json({ client_id: `${origin}/oauth-client-metadata.json`, - client_name: "My App", + client_name: "My Server App", client_uri: origin, redirect_uris: [`${origin}/oauth/callback`], token_endpoint_auth_method: "none", @@ -180,47 +312,41 @@ app.get("/oauth-client-metadata.json", (req, res) => { }); ``` -The `redirect_uris` array must include the `redirectUri` your client is configured with. - ## Re-exports -This package re-exports everything from `@happyview/oauth-client`, so you don't need to install the core package separately: +This package re-exports everything from `@happyview/oauth-client`, `@atproto-labs/handle-resolver`, and `@atproto-labs/did-resolver`. You don't need to install these packages separately: ```typescript import { + // From @happyview/oauth-client HappyViewNodeClient, HappyViewSession, + MemoryStorage, ApiError, + OAuthCallbackError, + Key, + type SessionEventHooks, type StorageAdapter, -} from "@happyview/oauth-client-node"; -``` + type TokenInfo, + type Jwk, -It also re-exports handle and DID resolution utilities from `@atproto-labs/handle-resolver` and `@atproto-labs/did-resolver`. + // From @atproto-labs/handle-resolver + AtprotoDohHandleResolver, -## Error handling - -All errors extend `HappyViewError`. The Node client additionally uses `OAuthCallbackError` for callback failures: + // From @atproto-labs/did-resolver + DidResolverCommon, + type DidDocument, +} from "@happyview/oauth-client-node"; +``` -| Error | When | -| --- | --- | -| `ApiError` | HappyView API returned a non-OK response (has `status` and `body`) | -| `OAuthCallbackError` | OAuth callback failed — wraps the callback params and underlying error | -| `InvalidStateError` | Missing or invalid OAuth/session state | -| `TokenExchangeError` | Token exchange with the PDS failed (has `status` and `body`) | -| `ResolutionError` | Handle or DID resolution failed | +## Differences from upstream -```typescript -import { - OAuthCallbackError, - InvalidStateError, -} from "@happyview/oauth-client-node"; +The HappyView SDK matches the upstream `@atproto/oauth-client-node` public API but differs architecturally: -try { - const { session } = await client.callback(params); -} catch (err) { - if (err instanceof OAuthCallbackError) { - console.error("OAuth callback failed:", err.message); - console.error("State:", err.state); - } -} -``` +| Area | Upstream | HappyView | +|------|----------|-----------| +| DPoP keys | Generated client-side | Provisioned from HappyView instance | +| Token refresh | Client-side with `refresh` param | Server-side (HappyView manages lifecycle) | +| `restore(did, refresh?)` | `refresh` controls token refresh behavior | `refresh` accepted but ignored | +| `session.getTokenInfo()` | Includes `expiresAt`/`expired` | These fields are `undefined` | +| `jwks` | Returns client's public keyset | Not applicable (no client keypairs) | diff --git a/packages/oauth-client-node/README.md b/packages/oauth-client-node/README.md new file mode 100644 index 0000000..10d026d --- /dev/null +++ b/packages/oauth-client-node/README.md @@ -0,0 +1,152 @@ +# @happyview/oauth-client-node + +Node.js OAuth client for authenticating with a [HappyView](https://github.com/gamesgamesgamesgamesgames/happyview) instance using AT Protocol. + +Built on top of [`@happyview/oauth-client`](https://www.npmjs.com/package/@happyview/oauth-client). Matches the API surface of [`@atproto/oauth-client-node`](https://www.npmjs.com/package/@atproto/oauth-client-node). + +## Installation + +```bash +npm install @happyview/oauth-client-node +``` + +## Usage + +### Setup + +```typescript +import { HappyViewNodeClient } from "@happyview/oauth-client-node"; + +const client = new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_your_client_key", + clientSecret: "hvs_your_secret", // optional, for confidential clients + redirectUri: "https://example.com/oauth/callback", + storage: myStorageAdapter, +}); +``` + +### Authorize + +Generate an authorization URL and redirect the user: + +```typescript +const url = await client.authorize("alice.bsky.social"); +// Redirect the user to url.href +``` + +With options: + +```typescript +const url = await client.authorize("alice.bsky.social", { + scope: "atproto transition:generic", + redirect_uri: "https://example.com/alt-callback", + prompt: "login", + display: "page", +}); +``` + +### Callback + +On your callback route, process the OAuth response: + +```typescript +const params = new URLSearchParams(req.url.split("?")[1]); +const { session, state } = await client.callback(params); +``` + +### Restore Session + +Restore a session by DID: + +```typescript +const session = await client.restore("did:plc:abc123"); +``` + +### Session + +```typescript +// Authenticated requests +const response = await session.fetchHandler( + "/xrpc/com.example.getStuff?limit=10", + { method: "GET" }, +); + +// Token metadata +const info = session.getTokenInfo(); +// { sub, scope, iss, aud } + +// Self-revoke +await session.signOut(); +``` + +### Using with @atproto/api + +```typescript +import { Agent } from "@atproto/api"; + +const agent = new Agent(session); +const profile = await agent.getProfile({ actor: agent.did }); +``` + +### Revoke Session + +```typescript +await client.revoke("did:plc:abc123"); +// or +await session.signOut(); +``` + +### Abort Request + +```typescript +const url = await client.authorize("alice.bsky.social"); +// ...later: +await client.abortRequest(url); +``` + +### Validate Client Metadata + +```typescript +const metadata = await HappyViewNodeClient.fetchMetadata({ + clientId: "https://example.com/oauth-client-metadata.json", +}); +``` + +### Identity Resolution + +```typescript +const did = await client.handleResolver.resolve("alice.bsky.social"); +const doc = await client.didResolver.resolve(did); +``` + +## Storage + +You must provide a `StorageAdapter`. The built-in `MemoryStorage` works for testing but won't survive restarts: + +```typescript +import { MemoryStorage } from "@happyview/oauth-client-node"; + +const client = new HappyViewNodeClient({ + // ... + storage: new MemoryStorage(), +}); +``` + +For production, implement the `StorageAdapter` interface backed by your database or cache: + +```typescript +interface StorageAdapter { + get(key: string): Promise; + set(key: string, value: string): Promise; + delete(key: string): Promise; +} +``` + +## Exports + +This package re-exports everything from `@happyview/oauth-client`, plus: + +- `HappyViewNodeClient` -- the main Node.js client +- `AuthorizeOptions`, `CallbackOptions`, `HappyViewNodeClientOptions` types diff --git a/packages/oauth-client-node/package.json b/packages/oauth-client-node/package.json new file mode 100644 index 0000000..a0e4e6d --- /dev/null +++ b/packages/oauth-client-node/package.json @@ -0,0 +1,52 @@ +{ + "name": "@happyview/oauth-client-node", + "version": "0.0.0-development", + "description": "HappyView OAuth client for Node.js ATProto DPoP authentication", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/gamesgamesgamesgamesgames/happyview.git", + "directory": "packages/oauth-client-node" + }, + "bugs": "https://github.com/gamesgamesgamesgamesgames/happyview/issues", + "homepage": "https://happyview.dev", + "publishConfig": { + "access": "public" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@happyview/oauth-client": "workspace:*", + "@atproto-labs/handle-resolver": "^0.3.6", + "@atproto-labs/did-resolver": "^0.2.6", + "@atproto/did": "^0.3.0" + }, + "devDependencies": { + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/github": "^12.0.6", + "@semantic-release/exec": "^7.0.0", + "@semantic-release/release-notes-generator": "^14.1.0", + "@types/bun": "^1.3.12", + "semantic-release": "^25.0.3", + "semantic-release-monorepo": "^8.0.2", + "tsup": "^8.0.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/oauth-client-node/src/__tests__/node-client.test.ts b/packages/oauth-client-node/src/__tests__/node-client.test.ts new file mode 100644 index 0000000..4d9fda0 --- /dev/null +++ b/packages/oauth-client-node/src/__tests__/node-client.test.ts @@ -0,0 +1,1227 @@ +import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"; +import { + HappyViewOAuthClient, + HappyViewError, + InvalidStateError, + MemoryStorage, + OAuthCallbackError, + TokenExchangeError, +} from "@happyview/oauth-client"; +import { HappyViewNodeClient } from "../node-client"; + +let testJwk: JsonWebKey; +beforeAll(async () => { + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + testJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); + delete testJwk.key_ops; +}); + +let storage: MemoryStorage; + +afterEach(() => { + storage = new MemoryStorage(); +}); + +function createClient(fetchFn?: typeof globalThis.fetch) { + storage = new MemoryStorage(); + return new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + redirectUri: "https://example.com/oauth/callback", + storage, + fetch: fetchFn, + }); +} + +function mockFetchForFullFlow() { + return mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + + if (url.includes("dns.google")) { + return new Response( + JSON.stringify({ + Status: 0, + Answer: [ + { + name: "_atproto.user.bsky.social.", + type: 16, + TTL: 300, + data: '"did=did:plc:abcdefghijklmnopqrstuvwx"', + }, + ], + }), + { status: 200, headers: { "content-type": "application/dns-json" } }, + ); + } + + if (url.includes("plc.directory")) { + return new Response( + JSON.stringify({ + id: "did:plc:abcdefghijklmnopqrstuvwx", + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://pds.example.com", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + if (url.includes(".well-known/oauth-protected-resource")) { + return new Response( + JSON.stringify({ + authorization_servers: ["https://pds.example.com"], + }), + { status: 200 }, + ); + } + + if (url.includes(".well-known/oauth-authorization-server")) { + return new Response( + JSON.stringify({ + issuer: "https://pds.example.com", + authorization_endpoint: "https://pds.example.com/oauth/authorize", + token_endpoint: "https://pds.example.com/oauth/token", + pushed_authorization_request_endpoint: + "https://pds.example.com/oauth/par", + }), + { status: 200 }, + ); + } + + if (url.includes("/oauth/dpop-keys")) { + return new Response( + JSON.stringify({ + provision_id: "hvp_test123", + dpop_key: testJwk, + }), + { status: 201 }, + ); + } + + if (url.includes("/oauth/par")) { + return new Response( + JSON.stringify({ + request_uri: "urn:ietf:params:oauth:request_uri:test", + expires_in: 60, + }), + { status: 201 }, + ); + } + + if (url.includes("/oauth/sessions") && init?.method === "POST") { + return new Response( + JSON.stringify({ + session_id: "sess_test", + did: "did:plc:abcdefghijklmnopqrstuvwx", + }), + { status: 201 }, + ); + } + + if (url.includes("/oauth/token")) { + return new Response( + JSON.stringify({ + access_token: "at_test_token", + refresh_token: "rt_test_token", + token_type: "DPoP", + scope: "atproto", + sub: "did:plc:abcdefghijklmnopqrstuvwx", + iss: "https://pds.example.com", + }), + { status: 200 }, + ); + } + + return new Response("not found", { status: 404 }); + }); +} + +describe("HappyViewNodeClient", () => { + test("constructor requires storage", () => { + const client = new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + redirectUri: "https://example.com/oauth/callback", + storage: new MemoryStorage(), + }); + expect(client).toBeDefined(); + }); + + test("authorize returns a URL object", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const url = await client.authorize("user.bsky.social"); + + expect(url).toBeInstanceOf(URL); + expect(url.hostname).toBe("pds.example.com"); + }); + + test("authorize stores pending auth state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const url = await client.authorize("user.bsky.social"); + + const params = new URLSearchParams(url.search); + const requestUri = params.get("request_uri"); + expect(requestUri).toBe("urn:ietf:params:oauth:request_uri:test"); + }); + + test("authorize uses custom scopes", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + scopes: "atproto transition:generic", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + expect(parCall).toBeDefined(); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("scope")).toBe("atproto transition:generic"); + }); + + test("authorize uses custom state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + state: "my-custom-state", + }); + + const stored = await storage.get("pending-auth:my-custom-state"); + expect(stored).not.toBeNull(); + }); + + test("callback exchanges code for tokens and returns session with state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:state123", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "state123", + issuer: "https://pds.example.com", + }), + ); + + const params = new URLSearchParams({ + code: "auth-code-123", + state: "state123", + }); + + const result = await client.callback(params); + expect(result.session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(result.state).toBe("state123"); + }); + + test("callback includes DPoP proof", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:statedpop", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statedpop", + issuer: "https://pds.example.com", + }), + ); + + await client.callback( + new URLSearchParams({ code: "auth-code", state: "statedpop" }), + ); + + const tokenCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/token"), + ); + expect(tokenCall).toBeDefined(); + const tokenHeaders = new Headers((tokenCall![1] as RequestInit).headers); + expect(tokenHeaders.get("dpop")).not.toBeNull(); + expect(tokenHeaders.get("dpop")!.split(".")).toHaveLength(3); + }); + + test("callback accepts redirect_uri override", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:stateuri", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "stateuri", + issuer: "https://pds.example.com", + }), + ); + + await client.callback( + new URLSearchParams({ code: "auth-code", state: "stateuri" }), + { redirect_uri: "https://other.example.com/callback" }, + ); + + const tokenCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/token"), + ); + const body = new URLSearchParams( + (tokenCall![1] as RequestInit).body as string, + ); + expect(body.get("redirect_uri")).toBe( + "https://other.example.com/callback", + ); + }); + + test("callback throws OAuthCallbackError when state is missing", async () => { + const client = createClient(); + try { + await client.callback(new URLSearchParams({ code: "auth-code" })); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(OAuthCallbackError); + expect((err as OAuthCallbackError).state).toBeUndefined(); + } + }); + + test("callback throws OAuthCallbackError when no pending state found", async () => { + const client = createClient(); + try { + await client.callback( + new URLSearchParams({ code: "auth-code", state: "nonexistent" }), + ); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(OAuthCallbackError); + expect((err as OAuthCallbackError).state).toBe("nonexistent"); + } + }); + + test("callback throws OAuthCallbackError wrapping TokenExchangeError on token failure", async () => { + const fetchFn = mock( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/oauth/token")) { + return new Response("invalid_grant", { status: 400 }); + } + return new Response("not found", { status: 404 }); + }, + ); + + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:statefail", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statefail", + issuer: "https://pds.example.com", + }), + ); + + try { + await client.callback( + new URLSearchParams({ code: "auth-code", state: "statefail" }), + ); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(OAuthCallbackError); + expect((err as OAuthCallbackError).state).toBe("statefail"); + expect((err as OAuthCallbackError).cause).toBeInstanceOf(TokenExchangeError); + expect(((err as OAuthCallbackError).cause as TokenExchangeError).status).toBe(400); + } + }); + + test("restore returns session for existing DID", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore( + "did:plc:abcdefghijklmnopqrstuvwx", + ); + expect(session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + test("restore throws when session does not exist", async () => { + const client = createClient(); + try { + await client.restore("did:plc:nonexistent"); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(InvalidStateError); + } + }); + + test("revoke deletes session", async () => { + const deleteFn = mock( + async (input: RequestInfo | URL, init?: RequestInit) => { + return new Response(null, { status: 204 }); + }, + ); + const client = createClient(deleteFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + await storage.set( + "happyview:last-active-did", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + + await client.revoke("did:plc:abcdefghijklmnopqrstuvwx"); + + const session = await storage.get( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + ); + expect(session).toBeNull(); + }); + + test("authorize uses scope (singular) option", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + scope: "atproto transition:generic", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + expect(parCall).toBeDefined(); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("scope")).toBe("atproto transition:generic"); + }); + + test("authorize prefers scope over scopes when both provided", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + scope: "atproto transition:generic", + scopes: "atproto", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("scope")).toBe("atproto transition:generic"); + }); + + test("restore throws when called with no DID", async () => { + const client = createClient(); + try { + await client.restore(); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(InvalidStateError); + } + }); + + test("restore accepts and ignores refresh parameter", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore( + "did:plc:abcdefghijklmnopqrstuvwx", + true, + ); + expect(session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + test("authorize without PAR falls back to direct URL", async () => { + const fetchFn = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + + if (url.includes("dns.google")) { + return new Response( + JSON.stringify({ + Status: 0, + Answer: [ + { + name: "_atproto.user.bsky.social.", + type: 16, + TTL: 300, + data: '"did=did:plc:abcdefghijklmnopqrstuvwx"', + }, + ], + }), + { status: 200, headers: { "content-type": "application/dns-json" } }, + ); + } + + if (url.includes("plc.directory")) { + return new Response( + JSON.stringify({ + id: "did:plc:abcdefghijklmnopqrstuvwx", + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://pds.example.com", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + if (url.includes(".well-known/oauth-protected-resource")) { + return new Response( + JSON.stringify({ + authorization_servers: ["https://pds.example.com"], + }), + { status: 200 }, + ); + } + + if (url.includes(".well-known/oauth-authorization-server")) { + return new Response( + JSON.stringify({ + issuer: "https://pds.example.com", + authorization_endpoint: "https://pds.example.com/oauth/authorize", + token_endpoint: "https://pds.example.com/oauth/token", + }), + { status: 200 }, + ); + } + + if (url.includes("/oauth/dpop-keys")) { + return new Response( + JSON.stringify({ + provision_id: "hvp_test123", + dpop_key: testJwk, + }), + { status: 201 }, + ); + } + + return new Response("not found", { status: 404 }); + }); + + const client = createClient(fetchFn); + const url = await client.authorize("user.bsky.social"); + + expect(url).toBeInstanceOf(URL); + expect(url.hostname).toBe("pds.example.com"); + expect(url.pathname).toBe("/oauth/authorize"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("client_id")).toBe( + "https://example.com/oauth-client-metadata.json", + ); + expect(url.searchParams.get("scope")).toBe("atproto"); + }); + + test("callback retries with DPoP nonce on use_dpop_nonce error", async () => { + let tokenAttempt = 0; + const fetchFn = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + + if (url.includes("/oauth/token")) { + tokenAttempt++; + if (tokenAttempt === 1) { + return new Response( + JSON.stringify({ error: "use_dpop_nonce" }), + { + status: 400, + headers: { "dpop-nonce": "server-nonce-123" }, + }, + ); + } + return new Response( + JSON.stringify({ + access_token: "at_test_token", + refresh_token: "rt_test_token", + scope: "atproto", + sub: "did:plc:abcdefghijklmnopqrstuvwx", + iss: "https://pds.example.com", + }), + { status: 200 }, + ); + } + + if (url.includes("/oauth/sessions") && init?.method === "POST") { + return new Response( + JSON.stringify({ + session_id: "sess_test", + did: "did:plc:abcdefghijklmnopqrstuvwx", + }), + { status: 201 }, + ); + } + + return new Response("not found", { status: 404 }); + }); + + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:statenonce", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statenonce", + issuer: "https://pds.example.com", + }), + ); + + const result = await client.callback( + new URLSearchParams({ code: "auth-code", state: "statenonce" }), + ); + + expect(result.session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(tokenAttempt).toBe(2); + + const secondTokenCall = fetchFn.mock.calls.filter((call: any[]) => + String(call[0]).includes("/oauth/token"), + )[1]; + const dpopJwt = new Headers( + (secondTokenCall![1] as RequestInit).headers, + ).get("dpop")!; + const payloadB64 = dpopJwt.split(".")[1]; + const padded = + payloadB64 + "=".repeat((4 - (payloadB64.length % 4)) % 4); + const payload = JSON.parse( + atob(padded.replace(/-/g, "+").replace(/_/g, "/")), + ); + expect(payload.nonce).toBe("server-nonce-123"); + }); + + test("abortRequest cleans up pending auth state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const url = await client.authorize("user.bsky.social", { + state: "abort-test", + }); + + const pendingBefore = await storage.get("pending-auth:abort-test"); + expect(pendingBefore).not.toBeNull(); + + await client.abortRequest(url); + + const pendingAfter = await storage.get("pending-auth:abort-test"); + expect(pendingAfter).toBeNull(); + }); + + test("abortRequest is a no-op for unknown URLs", async () => { + const client = createClient(); + await client.abortRequest(new URL("https://unknown.example.com/auth")); + }); + + test("confidential client passes clientSecret to base class", () => { + storage = new MemoryStorage(); + const client = new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + clientSecret: "hvs_test_secret", + redirectUri: "https://example.com/oauth/callback", + storage, + }); + expect(client.isConfidential).toBe(true); + }); + + test("public client is not confidential", () => { + const client = createClient(); + expect(client.isConfidential).toBe(false); + }); + + test("full authorize → callback flow", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const url = await client.authorize("user.bsky.social"); + expect(url).toBeInstanceOf(URL); + + // Simulate the PDS redirecting back with code and state + // Extract the state from stored pending auth + const keys: string[] = []; + // MemoryStorage doesn't expose keys, so find it via the authorize call + // The state is random, but we can find it by checking the PAR body + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const parBody = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + const state = parBody.get("state")!; + + const result = await client.callback( + new URLSearchParams({ code: "auth-code", state }), + ); + + expect(result.session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(result.state).toBe(state); + }); + + test("session.sub is an alias for session.did", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + expect(session.sub).toBe(session.did); + expect(session.sub).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + test("session.getTokenInfo returns available metadata", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + scopes: "atproto", + pdsUrl: "https://pds.example.com", + issuer: "https://pds.example.com", + }), + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + const info = session.getTokenInfo(); + expect(info.sub).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(info.scope).toBe("atproto"); + expect(info.aud).toBe("https://pds.example.com"); + expect(info.iss).toBe("https://pds.example.com"); + }); + + test("session.getTokenInfo works with legacy stored sessions (no extra fields)", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + const info = session.getTokenInfo(); + expect(info.sub).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(info.scope).toBeUndefined(); + expect(info.aud).toBeUndefined(); + expect(info.iss).toBeUndefined(); + }); + + test("session.signOut deletes the session", async () => { + const deleteFn = mock( + async (input: RequestInfo | URL, init?: RequestInit) => { + return new Response(null, { status: 204 }); + }, + ); + const client = createClient(deleteFn); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + await storage.set( + "happyview:last-active-did", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + await session.signOut(); + + const stored = await storage.get( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + ); + expect(stored).toBeNull(); + }); + + test("callback session includes token metadata", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await storage.set( + "pending-auth:statemeta", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statemeta", + issuer: "https://pds.example.com", + }), + ); + + const result = await client.callback( + new URLSearchParams({ code: "auth-code", state: "statemeta" }), + ); + + const info = result.session.getTokenInfo(); + expect(info.scope).toBe("atproto"); + expect(info.iss).toBe("https://pds.example.com"); + expect(info.aud).toBe("https://pds.example.com"); + }); + + test("authorize passes prompt option to PAR", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + prompt: "login", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("prompt")).toBe("login"); + }); + + test("authorize passes redirect_uri option", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + redirect_uri: "https://other.example.com/cb", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("redirect_uri")).toBe("https://other.example.com/cb"); + }); + + test("authorize passes display and ui_locales options", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.authorize("user.bsky.social", { + display: "popup", + ui_locales: "en fr", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("display")).toBe("popup"); + expect(body.get("ui_locales")).toBe("en fr"); + }); + + test("handleResolver and didResolver are publicly accessible", () => { + const client = createClient(); + expect(client.handleResolver).toBeDefined(); + expect(client.didResolver).toBeDefined(); + }); + + test("fetchMetadata fetches and returns client metadata JSON", async () => { + const metadata = { + client_id: "https://example.com/metadata.json", + client_name: "Test", + }; + const fetchFn = mock(async () => { + return new Response(JSON.stringify(metadata), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const result = await HappyViewOAuthClient.fetchMetadata({ + clientId: "https://example.com/metadata.json", + fetch: fetchFn as typeof globalThis.fetch, + }); + + expect(result.client_id).toBe("https://example.com/metadata.json"); + expect(result.client_name).toBe("Test"); + }); + + test("fetchMetadata throws on non-200 response", async () => { + const fetchFn = mock(async () => { + return new Response("not found", { status: 404 }); + }); + + try { + await HappyViewOAuthClient.fetchMetadata({ + clientId: "https://example.com/metadata.json", + fetch: fetchFn as typeof globalThis.fetch, + }); + expect(true).toBe(false); + } catch (err) { + expect((err as Error).message).toContain("404"); + } + }); + + test("fetchMetadata throws on non-JSON content type", async () => { + const fetchFn = mock(async () => { + return new Response("hi", { + status: 200, + headers: { "content-type": "text/html" }, + }); + }); + + try { + await HappyViewOAuthClient.fetchMetadata({ + clientId: "https://example.com/metadata.json", + fetch: fetchFn as typeof globalThis.fetch, + }); + expect(true).toBe(false); + } catch (err) { + expect((err as Error).message).toContain("content type"); + } + }); + + test("sessionHooks.onSessionUpdate fires after callback", async () => { + const onSessionUpdate = mock((did: string) => {}); + const fetchFn = mockFetchForFullFlow(); + storage = new MemoryStorage(); + const client = new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + redirectUri: "https://example.com/oauth/callback", + storage, + sessionHooks: { onSessionUpdate }, + fetch: fetchFn, + }); + + await storage.set( + "pending-auth:statehook", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "statehook", + issuer: "https://pds.example.com", + }), + ); + + await client.callback( + new URLSearchParams({ code: "auth-code", state: "statehook" }), + ); + + expect(onSessionUpdate).toHaveBeenCalledTimes(1); + expect(onSessionUpdate.mock.calls[0][0]).toBe( + "did:plc:abcdefghijklmnopqrstuvwx", + ); + }); + + test("sessionHooks.onSessionDelete fires after revoke", async () => { + const onSessionDelete = mock((did: string) => {}); + const deleteFn = mock(async () => new Response(null, { status: 204 })); + storage = new MemoryStorage(); + const client = new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + redirectUri: "https://example.com/oauth/callback", + storage, + sessionHooks: { onSessionDelete }, + fetch: deleteFn, + }); + + await storage.set( + "happyview:session:did:plc:abcdefghijklmnopqrstuvwx", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + await client.revoke("did:plc:abcdefghijklmnopqrstuvwx"); + + expect(onSessionDelete).toHaveBeenCalledTimes(1); + expect(onSessionDelete.mock.calls[0][0]).toBe( + "did:plc:abcdefghijklmnopqrstuvwx", + ); + }); + + test("callback throws OAuthCallbackError when params contain error", async () => { + const client = createClient(); + + await storage.set( + "pending-auth:stateerr", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "pv", + authPkceVerifier: "av", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "stateerr", + issuer: "https://pds.example.com", + }), + ); + + try { + await client.callback( + new URLSearchParams({ + error: "access_denied", + error_description: "User denied access", + state: "stateerr", + }), + ); + expect(true).toBe(false); + } catch (err) { + expect(err).toBeInstanceOf(OAuthCallbackError); + const oauthErr = err as OAuthCallbackError; + expect(oauthErr.state).toBe("stateerr"); + expect(oauthErr.params.get("error")).toBe("access_denied"); + expect(oauthErr.message).toBe("User denied access"); + } + }); + + test("callback cleans up pending state on error param", async () => { + const client = createClient(); + + await storage.set( + "pending-auth:stateclean", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "pv", + authPkceVerifier: "av", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "stateclean", + issuer: "https://pds.example.com", + }), + ); + + try { + await client.callback( + new URLSearchParams({ + error: "access_denied", + state: "stateclean", + }), + ); + } catch { + // expected + } + + const pending = await storage.get("pending-auth:stateclean"); + expect(pending).toBeNull(); + }); +}); + +describe("OAuthCallbackError", () => { + test("extends HappyViewError", () => { + const err = new OAuthCallbackError(new URLSearchParams(), "test"); + expect(err).toBeInstanceOf(HappyViewError); + expect(err).toBeInstanceOf(Error); + }); + + test("uses error_description from params when no message given", () => { + const params = new URLSearchParams({ + error: "access_denied", + error_description: "User denied", + }); + const err = new OAuthCallbackError(params); + expect(err.message).toBe("User denied"); + }); + + test("falls back to default message when no description or message", () => { + const err = new OAuthCallbackError(new URLSearchParams()); + expect(err.message).toBe("OAuth callback error"); + }); + + test("explicit message overrides error_description", () => { + const params = new URLSearchParams({ + error_description: "from params", + }); + const err = new OAuthCallbackError(params, "explicit message"); + expect(err.message).toBe("explicit message"); + }); + + test("preserves params and state", () => { + const params = new URLSearchParams({ code: "abc", state: "xyz" }); + const err = new OAuthCallbackError(params, "msg", "xyz"); + expect(err.params).toBe(params); + expect(err.state).toBe("xyz"); + }); + + test("preserves cause when provided", () => { + const cause = new Error("original"); + const err = new OAuthCallbackError( + new URLSearchParams(), + "wrapped", + "s1", + cause, + ); + expect(err.cause).toBe(cause); + }); + + test("from() returns same instance for OAuthCallbackError input", () => { + const original = new OAuthCallbackError( + new URLSearchParams(), + "original", + "s1", + ); + const result = OAuthCallbackError.from( + original, + new URLSearchParams(), + "s2", + ); + expect(result).toBe(original); + }); + + test("from() wraps Error with message and cause", () => { + const cause = new TokenExchangeError("exchange failed", 400, "body"); + const params = new URLSearchParams({ state: "s1" }); + const result = OAuthCallbackError.from(cause, params, "s1"); + expect(result).toBeInstanceOf(OAuthCallbackError); + expect(result.message).toBe("exchange failed"); + expect(result.state).toBe("s1"); + expect(result.cause).toBe(cause); + }); + + test("from() wraps non-Error with undefined message", () => { + const params = new URLSearchParams({ + error_description: "desc from params", + }); + const result = OAuthCallbackError.from("string error", params, "s1"); + expect(result).toBeInstanceOf(OAuthCallbackError); + expect(result.message).toBe("desc from params"); + expect(result.cause).toBe("string error"); + }); +}); + +describe("re-exports from sub-packages", () => { + test("re-exports Key from @atproto/jwk via index", async () => { + const mod = await import("../index"); + expect(mod.Key).toBeDefined(); + }); + + test("re-exports AtprotoDohHandleResolver from @atproto-labs/handle-resolver via index", async () => { + const mod = await import("../index"); + expect(mod.AtprotoDohHandleResolver).toBeDefined(); + }); + + test("re-exports DidResolverCommon from @atproto-labs/did-resolver via index", async () => { + const mod = await import("../index"); + expect(mod.DidResolverCommon).toBeDefined(); + }); + + test("re-exports HappyViewSession from @happyview/oauth-client via index", async () => { + const mod = await import("../index"); + expect(mod.HappyViewSession).toBeDefined(); + }); + + test("re-exports OAuthCallbackError from @happyview/oauth-client via index", async () => { + const mod = await import("../index"); + expect(mod.OAuthCallbackError).toBeDefined(); + }); +}); diff --git a/packages/oauth-client-node/src/index.ts b/packages/oauth-client-node/src/index.ts new file mode 100644 index 0000000..16aca9b --- /dev/null +++ b/packages/oauth-client-node/src/index.ts @@ -0,0 +1,10 @@ +export * from "@happyview/oauth-client"; +export * from "@atproto-labs/handle-resolver"; +export * from "@atproto-labs/did-resolver"; + +export { HappyViewNodeClient } from "./node-client"; +export type { + AuthorizeOptions, + CallbackOptions, + HappyViewNodeClientOptions, +} from "./node-client"; diff --git a/packages/oauth-client-node/src/node-client.ts b/packages/oauth-client-node/src/node-client.ts new file mode 100644 index 0000000..2ca23f9 --- /dev/null +++ b/packages/oauth-client-node/src/node-client.ts @@ -0,0 +1,445 @@ +import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; +import { DidResolverCommon } from "@atproto-labs/did-resolver"; +import type { DidDocument } from "@atproto/did"; +import { + HappyViewOAuthClient, + HappyViewSession, + importJwk, + InvalidStateError, + OAuthCallbackError, + ResolutionError, + TokenExchangeError, + type SessionEventHooks, + type StorageAdapter, +} from "@happyview/oauth-client"; + +export interface HappyViewNodeClientOptions { + instanceUrl: string; + clientId: string; + clientKey: string; + clientSecret?: string; + redirectUri: string; + scopes?: string; + storage: StorageAdapter; + sessionHooks?: SessionEventHooks; + fetch?: typeof globalThis.fetch; +} + +export interface AuthorizeOptions { + scope?: string; + /** @deprecated Use `scope` instead. */ + scopes?: string; + state?: string; + redirect_uri?: string; + signal?: AbortSignal; + display?: "page" | "popup" | "touch" | "wap"; + prompt?: string; + nonce?: string; + max_age?: number; + ui_locales?: string; + dpop_jkt?: string; + claims?: Record>>; + authorization_details?: unknown[]; + id_token_hint?: string; +} + +export interface CallbackOptions { + redirect_uri?: string; +} + +interface PendingAuthState { + did: string; + provisionId: string; + rawJwk: JsonWebKey; + provisionPkceVerifier: string; + authPkceVerifier: string; + pdsUrl: string; + tokenEndpoint: string; + state: string; + issuer: string; +} + +interface AuthServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + pushed_authorization_request_endpoint?: string; + dpop_signing_alg_values_supported?: string[]; +} + +export class HappyViewNodeClient extends HappyViewOAuthClient { + readonly handleResolver: AtprotoDohHandleResolver; + readonly didResolver: DidResolverCommon; + private readonly clientId: string; + private readonly redirectUri: string; + private readonly scopes: string; + + constructor(options: HappyViewNodeClientOptions) { + const fetchFn = + options.fetch ?? + (((input: RequestInfo | URL, init?: RequestInit) => + fetch(input, init)) as typeof globalThis.fetch); + + super({ + instanceUrl: options.instanceUrl, + clientKey: options.clientKey, + clientSecret: options.clientSecret, + storage: options.storage, + sessionHooks: options.sessionHooks, + fetch: fetchFn, + }); + + this.clientId = options.clientId; + this.redirectUri = options.redirectUri; + this.scopes = options.scopes ?? "atproto"; + this.handleResolver = new AtprotoDohHandleResolver({ + dohEndpoint: "https://dns.google/resolve", + fetch: fetchFn, + }); + this.didResolver = new DidResolverCommon({ fetch: fetchFn }); + } + + async authorize( + handle: string, + options?: AuthorizeOptions, + ): Promise { + const resolvedDid = await this.handleResolver.resolve(handle); + if (!resolvedDid) { + throw new ResolutionError(`Failed to resolve handle: ${handle}`); + } + const did = resolvedDid as string; + + const didDoc = await this.didResolver.resolve(resolvedDid); + const pdsUrl = extractPdsUrl(didDoc); + const authMeta = await this.fetchAuthServerMetadata(pdsUrl); + + const scopes = options?.scope ?? options?.scopes ?? this.scopes; + + const { provisionId, rawJwk, pkceVerifier: provisionPkceVerifier } = + await this.provisionDpopKey(); + + const authPkceVerifier = generatePkceVerifier(); + const authPkceChallenge = await computePkceChallenge(authPkceVerifier); + + const state = options?.state ?? randomHex(16); + + const pendingState: PendingAuthState = { + did, + provisionId, + rawJwk, + provisionPkceVerifier: provisionPkceVerifier!, + authPkceVerifier, + pdsUrl, + tokenEndpoint: authMeta.token_endpoint, + state, + issuer: authMeta.issuer, + }; + await this.storage.set( + `pending-auth:${state}`, + JSON.stringify(pendingState), + ); + + const redirectUri = options?.redirect_uri ?? this.redirectUri; + + const authParams = new URLSearchParams({ + response_type: "code", + client_id: this.clientId, + redirect_uri: redirectUri, + state, + scope: scopes, + code_challenge: authPkceChallenge, + code_challenge_method: "S256", + login_hint: handle, + }); + + if (options?.display) authParams.set("display", options.display); + if (options?.prompt) authParams.set("prompt", options.prompt); + if (options?.nonce) authParams.set("nonce", options.nonce); + if (options?.max_age != null) authParams.set("max_age", String(options.max_age)); + if (options?.ui_locales) authParams.set("ui_locales", options.ui_locales); + if (options?.dpop_jkt) authParams.set("dpop_jkt", options.dpop_jkt); + if (options?.id_token_hint) authParams.set("id_token_hint", options.id_token_hint); + if (options?.claims) authParams.set("claims", JSON.stringify(options.claims)); + if (options?.authorization_details) authParams.set("authorization_details", JSON.stringify(options.authorization_details)); + + const parEndpoint = authMeta.pushed_authorization_request_endpoint; + if (parEndpoint) { + const parResp = await this._fetch(parEndpoint, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: authParams, + }); + + if (!parResp.ok) { + const err = await parResp.text(); + throw new ResolutionError( + `PAR request failed: ${parResp.status} ${err}`, + ); + } + + const parData = (await parResp.json()) as { request_uri: string }; + const url = new URL( + `${authMeta.authorization_endpoint}?` + + new URLSearchParams({ + client_id: this.clientId, + request_uri: parData.request_uri, + }), + ); + await this.storage.set(`pending-auth-url:${url.href}`, state); + return url; + } + + const url = new URL( + `${authMeta.authorization_endpoint}?${authParams}`, + ); + await this.storage.set(`pending-auth-url:${url.href}`, state); + return url; + } + + async callback( + params: URLSearchParams, + options?: CallbackOptions, + ): Promise<{ session: HappyViewSession; state: string | null }> { + const code = params.get("code"); + const state = params.get("state"); + + if (!state) { + throw new OAuthCallbackError(params, 'Missing "state" parameter'); + } + + const pendingJson = await this.storage.get(`pending-auth:${state}`); + if (!pendingJson) { + throw new OAuthCallbackError( + params, + `Unknown authorization session "${state}"`, + state, + ); + } + + if (params.has("error")) { + await this.storage.delete(`pending-auth:${state}`); + throw new OAuthCallbackError(params, undefined, state); + } + + if (!code) { + throw new OAuthCallbackError( + params, + 'Missing "code" parameter', + state, + ); + } + const pending: PendingAuthState = JSON.parse(pendingJson); + + try { + const dpopKey = await importJwk(pending.rawJwk); + const { d: _, ...publicJwk } = pending.rawJwk; + const redirectUri = options?.redirect_uri ?? this.redirectUri; + + let dpopNonce: string | undefined; + let tokenResp!: Response; + + for (let attempt = 0; attempt < 2; attempt++) { + const proof = await dpopKey.createJwt( + { + alg: "ES256", + typ: "dpop+jwt", + jwk: publicJwk as any, + }, + { + htm: "POST", + htu: pending.tokenEndpoint, + iat: Math.floor(Date.now() / 1000), + jti: randomHex(16), + ...(dpopNonce ? { nonce: dpopNonce } : {}), + }, + ); + + tokenResp = await this._fetch(pending.tokenEndpoint, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + dpop: proof, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: this.clientId, + code_verifier: pending.authPkceVerifier, + }), + }); + + if (!tokenResp.ok && attempt === 0) { + const nonceHeader = tokenResp.headers.get("dpop-nonce"); + if (nonceHeader) { + const errorBody = await tokenResp.text(); + if (errorBody.includes("use_dpop_nonce")) { + dpopNonce = nonceHeader; + continue; + } + throw new TokenExchangeError( + `Token exchange failed: ${tokenResp.status} ${errorBody}`, + tokenResp.status, + errorBody, + ); + } + } + + break; + } + + if (!tokenResp!.ok) { + const err = await tokenResp!.text(); + throw new TokenExchangeError( + `Token exchange failed: ${tokenResp!.status} ${err}`, + tokenResp!.status, + err, + ); + } + + const tokens = (await tokenResp.json()) as { + access_token: string; + refresh_token?: string; + scope?: string; + sub?: string; + iss?: string; + }; + + const session = await this.registerSession({ + provisionId: pending.provisionId, + pkceVerifier: pending.provisionPkceVerifier, + did: pending.did, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + scopes: tokens.scope ?? this.scopes, + pdsUrl: pending.pdsUrl, + issuer: tokens.iss ?? pending.issuer, + dpopKey: pending.rawJwk, + }); + + await this.storage.delete(`pending-auth:${state}`); + + return { session, state }; + } catch (err) { + throw OAuthCallbackError.from(err, params, state); + } + } + + override async restore(did?: string, _refresh?: boolean | "auto"): Promise { + if (!did) { + throw new InvalidStateError( + "DID is required for restore() in the Node client", + ); + } + const session = await this.restoreSession(did); + if (!session) { + throw new InvalidStateError(`No session found for ${did}`); + } + return session; + } + + async revoke(did: string): Promise { + await this.deleteSession(did); + } + + async abortRequest(url: URL): Promise { + const urlKey = `pending-auth-url:${url.href}`; + const state = await this.storage.get(urlKey); + if (state) { + await this.storage.delete(`pending-auth:${state}`); + await this.storage.delete(urlKey); + } + } + + private async fetchAuthServerMetadata( + pdsUrl: string, + ): Promise { + const base = pdsUrl.replace(/\/+$/, ""); + + const resourceResp = await this._fetch( + `${base}/.well-known/oauth-protected-resource`, + ); + if (!resourceResp.ok) { + throw new ResolutionError( + `Failed to fetch protected resource metadata from ${pdsUrl}: ${resourceResp.status}`, + ); + } + const resource = (await resourceResp.json()) as { + authorization_servers?: string[]; + }; + const authServer = resource.authorization_servers?.[0]; + if (!authServer) { + throw new ResolutionError( + `No authorization server found in protected resource metadata from ${pdsUrl}`, + ); + } + + const metaResp = await this._fetch( + `${authServer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`, + ); + if (!metaResp.ok) { + throw new ResolutionError( + `Failed to fetch auth server metadata from ${authServer}: ${metaResp.status}`, + ); + } + return metaResp.json() as Promise; + } +} + +function extractPdsUrl(doc: DidDocument): string { + const services = doc.service ?? []; + for (const service of services) { + if ( + service.id === "#atproto_pds" || + (typeof service.id === "string" && + service.id.endsWith("#atproto_pds")) + ) { + if (typeof service.serviceEndpoint === "string") { + return service.serviceEndpoint; + } + throw new ResolutionError( + `#atproto_pds service endpoint is not a string URL in DID document for ${doc.id}`, + ); + } + } + throw new ResolutionError( + `No #atproto_pds service found in DID document for ${doc.id}`, + ); +} + +function randomHex(byteLength: number): string { + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join( + "", + ); +} + +function generatePkceVerifier(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function computePkceChallenge(verifier: string): Promise { + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(verifier), + ); + const bytes = new Uint8Array(hash); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} diff --git a/packages/oauth-client-node/tsconfig.json b/packages/oauth-client-node/tsconfig.json new file mode 100644 index 0000000..30b8260 --- /dev/null +++ b/packages/oauth-client-node/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/__tests__"] +} diff --git a/packages/oauth-client-node/tsup.config.ts b/packages/oauth-client-node/tsup.config.ts new file mode 100644 index 0000000..ff1a153 --- /dev/null +++ b/packages/oauth-client-node/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + clean: true, +});