From 4b4b8838369d2a012d03466cb2a998e57cebc284 Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 7 May 2026 08:55:06 -0500 Subject: [PATCH] feat: add support for session hooks --- bun.lock | 27 +- .../docs/docs/sdk/oauth-client-browser.md | 169 +++++++- packages/docs/docs/sdk/overview.md | 6 +- .../src/__tests__/browser-client.test.ts | 381 +++++++++++++++++- .../src/__tests__/util.test.ts | 89 ++++ .../src/browser-client.ts | 239 ++++++----- packages/oauth-client-browser/src/index.ts | 20 +- packages/oauth-client-browser/src/util.ts | 19 + packages/oauth-client-browser/tsconfig.json | 2 +- packages/oauth-client/src/client.ts | 47 ++- packages/oauth-client/src/errors.ts | 34 ++ packages/oauth-client/src/index.ts | 7 + packages/oauth-client/src/session.ts | 32 ++ packages/oauth-client/src/types.ts | 18 + 14 files changed, 944 insertions(+), 146 deletions(-) create mode 100644 packages/oauth-client-browser/src/__tests__/util.test.ts create mode 100644 packages/oauth-client-browser/src/util.ts diff --git a/bun.lock b/bun.lock index cb20426..0ff328b 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,7 @@ "name": "@happyview/lex-agent", "version": "0.0.0-development", "dependencies": { - "@happyview/oauth-client": "^1.0.0-dev.2", + "@happyview/oauth-client": "workspace:*", }, "devDependencies": { "@atproto/lex": "^0.0.25", @@ -87,6 +87,27 @@ "typescript": "^5.7.0", }, }, + "packages/oauth-client-node": { + "name": "@happyview/oauth-client-node", + "version": "0.0.0-development", + "dependencies": { + "@atproto-labs/did-resolver": "^0.2.6", + "@atproto-labs/handle-resolver": "^0.3.6", + "@atproto/did": "^0.3.0", + "@happyview/oauth-client": "workspace:*", + }, + "devDependencies": { + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/exec": "^7.0.0", + "@semantic-release/github": "^12.0.6", + "@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", + }, + }, }, "overrides": { "webpack": "5.97.1", @@ -636,6 +657,8 @@ "@happyview/oauth-client-browser": ["@happyview/oauth-client-browser@workspace:packages/oauth-client-browser"], + "@happyview/oauth-client-node": ["@happyview/oauth-client-node@workspace:packages/oauth-client-node"], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], @@ -3070,8 +3093,6 @@ "@docusaurus/types/webpack-merge": ["webpack-merge@5.10.0", "", { "dependencies": { "clone-deep": "^4.0.1", "flat": "^5.0.2", "wildcard": "^2.0.0" } }, "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA=="], - "@happyview/lex-agent/@happyview/oauth-client": ["@happyview/oauth-client@1.0.0", "", {}, "sha512-hvVeyVHJStZ/nrqolcWfy39AJuaws0axKx7b3UvXTSi2TmnTeCJOnelnHstt0L9S6P6b7xxP8ujzvcWZsOd0Ig=="], - "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@17.67.0", "", { "dependencies": { "@jsonjoy.com/base64": "17.67.0", "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0", "@jsonjoy.com/json-pointer": "17.67.0", "@jsonjoy.com/util": "17.67.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w=="], "@jsonjoy.com/fs-snapshot/@jsonjoy.com/util": ["@jsonjoy.com/util@17.67.0", "", { "dependencies": { "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew=="], diff --git a/packages/docs/docs/sdk/oauth-client-browser.md b/packages/docs/docs/sdk/oauth-client-browser.md index c21b21e..bddc349 100644 --- a/packages/docs/docs/sdk/oauth-client-browser.md +++ b/packages/docs/docs/sdk/oauth-client-browser.md @@ -30,6 +30,7 @@ const client = new HappyViewBrowserClient({ | `redirectUri` | No | OAuth callback URL. Defaults to `${window.location.origin}/oauth/callback` | | `scopes` | No | OAuth scopes to request. Defaults to `"atproto"` | | `storage` | No | Custom storage adapter. Defaults to localStorage | +| `sessionHooks`| No | Event hooks for session lifecycle events | | `fetch` | No | Custom fetch implementation | The client uses localStorage by default. You can override it: @@ -148,7 +149,9 @@ if (params) { } ``` -## Authenticated requests +## Session + +### Authenticated requests The session's `fetchHandler` attaches DPoP proof headers automatically: @@ -163,32 +166,128 @@ const data = await response.json(); Pass a relative path (prepends the HappyView instance URL) or a full URL (used as-is). +### Token info + +```typescript +const info = session.getTokenInfo(); +// { sub, scope, iss, aud } +``` + +### Properties + +| 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 +await session.signOut(); +``` + +This is equivalent to calling `client.revoke(session.did)`. + +## Session event hooks + +React to session lifecycle events with `sessionHooks`: + +```typescript +const client = new HappyViewBrowserClient({ + // ... + 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()`, `logout()`, 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-browser"; + +try { + const session = await client.callback(); +} 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`. Pass it as the session manager: + +```typescript +import { Agent } from "@atproto/api"; + +const result = await client.init(); +if (result) { + const agent = new Agent(result.session); + + // Use the full @atproto/api surface + const profile = await agent.getProfile({ actor: agent.did }); + await agent.like(postUri, postCid); +} +``` + +This works because `HappyViewSession` implements the `SessionManager` interface that `Agent` expects — it has `did` and a `fetchHandler` that attaches DPoP authentication headers and prepends the HappyView instance URL. + ## Revoke session +From the client: + ```typescript await client.revoke(session.did); ``` +Or from the session itself: + +```typescript +await session.signOut(); +``` + :::note `logout()` still works as an alias for `revoke()`. ::: -## Resolution utilities +## Identity resolution -The browser client exports the resolution functions it uses internally. These are useful if you need to resolve handles or discover PDS URLs outside of the login flow: +The client exposes its handle and DID resolvers for advanced use: ```typescript -import { - resolveHandleToDid, - resolveDidDocument, - resolvePdsUrl, - resolveAuthServerMetadata, -} from "@happyview/oauth-client-browser"; +const did = await client.handleResolver.resolve("alice.bsky.social"); +const doc = await client.didResolver.resolve(did); +``` + +## Validate client metadata -const did = await resolveHandleToDid("alice.bsky.social"); -const doc = await resolveDidDocument(did); -const pdsUrl = resolvePdsUrl(doc); -const authMeta = await resolveAuthServerMetadata(pdsUrl); +Verify that your OAuth client metadata is served correctly: + +```typescript +import { HappyViewBrowserClient } from "@happyview/oauth-client-browser"; + +const metadata = await HappyViewBrowserClient.fetchMetadata({ + clientId: "https://example.com/oauth-client-metadata.json", +}); +console.log(metadata.client_name); ``` ## OAuth client metadata @@ -222,16 +321,56 @@ For a static site, serve a plain JSON file at `/oauth-client-metadata.json`. The `redirect_uris` array must include the `redirectUri` your client is configured with (defaults to `${origin}/oauth/callback`). +## Local development + +For local development with ATProto's loopback client ID convention, use `buildLoopbackClientId`: + +```typescript +import { buildLoopbackClientId } from "@happyview/oauth-client-browser"; + +const clientId = buildLoopbackClientId(window.location); +// → "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2F" +``` + +This builds a client ID that authorization servers recognize as a local development app. The `redirect_uri` is encoded in the client ID URL query string. + +## Cleanup + +The browser client implements `AsyncDisposable` for use with `await using`: + +```typescript +await using client = new HappyViewBrowserClient({ ... }); +// client.dispose() called automatically when scope exits +``` + +Or call `dispose()` manually: + +```typescript +client.dispose(); +``` + ## Re-exports -This package re-exports everything from `@happyview/oauth-client`, so you don't need to install the core package separately. All types, error classes, and utilities are available: +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 HappyViewBrowserClient, HappyViewSession, ApiError, - type CryptoAdapter, + OAuthCallbackError, + Key, + type SessionEventHooks, type StorageAdapter, + type TokenInfo, + type Jwk, + + // From @atproto-labs/handle-resolver + AtprotoDohHandleResolver, + + // From @atproto-labs/did-resolver + DidResolverCommon, + type DidDocument, } from "@happyview/oauth-client-browser"; ``` diff --git a/packages/docs/docs/sdk/overview.md b/packages/docs/docs/sdk/overview.md index d2efce2..e072183 100644 --- a/packages/docs/docs/sdk/overview.md +++ b/packages/docs/docs/sdk/overview.md @@ -7,6 +7,7 @@ HappyView provides JavaScript packages for building third-party apps that authen | [`@happyview/lex-agent`](https://npmx.dev/package/@happyview/lex-agent) | Recommended — type-safe XRPC via [`@atproto/lex`](https://npmx.dev/package/@atproto/lex) `Client` with HappyView DPoP auth | | [`@happyview/oauth-client`](https://npmx.dev/package/@happyview/oauth-client) | Platform-agnostic core — DPoP key provisioning, session management, authenticated fetch | | [`@happyview/oauth-client-browser`](https://npmx.dev/package/@happyview/oauth-client-browser) | Browser OAuth wrapper for apps already using `@atproto/oauth-client-browser` | +| [`@happyview/oauth-client-node`](https://npmx.dev/package/@happyview/oauth-client-node) | Node.js OAuth client for server-side apps, matching `@atproto/oauth-client-node` | ## Which package do I need? @@ -14,7 +15,9 @@ HappyView provides JavaScript packages for building third-party apps that authen **Already using `@atproto/oauth-client-browser`?** Add `@happyview/oauth-client-browser` to get a `HappyViewBrowserClient` that handles the HappyView-specific DPoP key provisioning and session registration on top of the standard atproto OAuth flow. -**Building a server-side app or something more custom?** Use `@happyview/oauth-client` directly and provide your own `CryptoAdapter` and `StorageAdapter`. +**Building a server-side (Node.js) app?** Use `@happyview/oauth-client-node` — it handles handle resolution, DID resolution, PDS discovery, and the full OAuth flow server-side. Matches the API surface of `@atproto/oauth-client-node`. + +**Building something more custom?** Use `@happyview/oauth-client` directly and provide your own `CryptoAdapter` and `StorageAdapter`. ## How it works @@ -65,4 +68,5 @@ const result = await lex.xrpc(myLexicons.com.example.getGame, { - [Lex Agent](./lex-agent.md): type-safe XRPC with `@atproto/lex` - [OAuth Client](./oauth-client.md): platform-agnostic core client - [Browser Client](./oauth-client-browser.md): browser OAuth redirect flow +- [Node Client](./oauth-client-node.md): server-side OAuth flow - [Authentication](../getting-started/authentication.md): full details on DPoP key provisioning and API client types diff --git a/packages/oauth-client-browser/src/__tests__/browser-client.test.ts b/packages/oauth-client-browser/src/__tests__/browser-client.test.ts index d6e26c1..41a4954 100644 --- a/packages/oauth-client-browser/src/__tests__/browser-client.test.ts +++ b/packages/oauth-client-browser/src/__tests__/browser-client.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"; import { - InvalidStateError, + HappyViewOAuthClient, + OAuthCallbackError, TokenExchangeError, type StorageAdapter, } from "@happyview/oauth-client"; @@ -402,27 +403,29 @@ describe("HappyViewBrowserClient", () => { ); }); - test("callback throws InvalidStateError when code or state is missing", async () => { + test("callback throws OAuthCallbackError when state is missing", async () => { const client = createClient(); try { await client.callback("?code=auth-code"); expect(true).toBe(false); } catch (err) { - expect(err).toBeInstanceOf(InvalidStateError); + expect(err).toBeInstanceOf(OAuthCallbackError); + expect((err as OAuthCallbackError).state).toBeUndefined(); } }); - test("callback throws InvalidStateError when no pending state found", async () => { + test("callback throws OAuthCallbackError when no pending state found", async () => { const client = createClient(); try { await client.callback("?code=auth-code&state=nonexistent"); expect(true).toBe(false); } catch (err) { - expect(err).toBeInstanceOf(InvalidStateError); + expect(err).toBeInstanceOf(OAuthCallbackError); + expect((err as OAuthCallbackError).state).toBe("nonexistent"); } }); - test("callback throws TokenExchangeError on token endpoint failure", async () => { + test("callback throws OAuthCallbackError wrapping TokenExchangeError on token failure", async () => { const fetchFn = mock( async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -455,9 +458,10 @@ describe("HappyViewBrowserClient", () => { await client.callback("?code=auth-code&state=statefail"); expect(true).toBe(false); } catch (err) { - expect(err).toBeInstanceOf(TokenExchangeError); - expect((err as TokenExchangeError).status).toBe(400); - expect((err as TokenExchangeError).body).toBe("invalid_grant"); + 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); } }); @@ -775,10 +779,369 @@ describe("HappyViewBrowserClient", () => { expect(() => client.dispose()).not.toThrow(); }); + test("prepareLogin accepts scope (singular) option", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.prepareLogin("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("prepareLogin prefers scope over scopes when both provided", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.prepareLogin("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 accepts and ignores refresh parameter", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(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).not.toBeNull(); + expect(session!.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + 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); + + localStorage.setItem( + "@happyview/oauth(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 session = await client.callback("?code=auth-code&state=statenonce"); + expect(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("session.sub is an alias for session.did", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + localStorage.setItem( + "@happyview/oauth(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); + }); + + test("session.getTokenInfo returns metadata from stored session", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(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 transition:generic", + 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 transition:generic"); + expect(info.aud).toBe("https://pds.example.com"); + expect(info.iss).toBe("https://pds.example.com"); + }); + + 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); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + localStorage.setItem( + "@happyview/oauth(happyview:last-active-did)", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + + const session = await client.restore("did:plc:abcdefghijklmnopqrstuvwx"); + await session!.signOut(); + + expect( + localStorage.getItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + ), + ).toBeNull(); + }); + + test("prepareLogin passes prompt option to PAR", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.prepareLogin("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("prepareLogin passes redirect_uri option", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.prepareLogin("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("handleResolver and didResolver are publicly accessible", () => { + const client = createClient(); + expect(client.handleResolver).toBeDefined(); + expect(client.didResolver).toBeDefined(); + }); + test("LoginContinuedInParentWindowError has correct name and message", () => { const err = new LoginContinuedInParentWindowError(); expect(err.name).toBe("LoginContinuedInParentWindowError"); expect(err.message).toBe("Login continued in parent window"); expect(err).toBeInstanceOf(Error); }); + + test("sessionHooks.onSessionUpdate fires after callback", async () => { + const onSessionUpdate = mock((did: string) => {}); + const fetchFn = mockFetchForFullFlow(); + const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + storage: new LocalStorageAdapter(), + sessionHooks: { onSessionUpdate }, + fetch: fetchFn, + }); + + localStorage.setItem( + "@happyview/oauth(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("?code=auth-code&state=statehook"); + + expect(onSessionUpdate).toHaveBeenCalledTimes(1); + expect(onSessionUpdate.mock.calls[0][0]).toBe( + "did:plc:abcdefghijklmnopqrstuvwx", + ); + }); + + test("sessionHooks.onSessionDelete fires after logout", async () => { + const onSessionDelete = mock((did: string) => {}); + const deleteFn = mock(async () => new Response(null, { status: 204 })); + const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + storage: new LocalStorageAdapter(), + sessionHooks: { onSessionDelete }, + fetch: deleteFn, + }); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + localStorage.setItem( + "@happyview/oauth(happyview:last-active-did)", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + + await client.logout("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(); + + localStorage.setItem( + "@happyview/oauth(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( + "?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("[Symbol.asyncDispose] calls dispose", async () => { + const client = createClient(); + await client[Symbol.asyncDispose](); + }); }); diff --git a/packages/oauth-client-browser/src/__tests__/util.test.ts b/packages/oauth-client-browser/src/__tests__/util.test.ts new file mode 100644 index 0000000..3ae7bc2 --- /dev/null +++ b/packages/oauth-client-browser/src/__tests__/util.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { buildLoopbackClientId } from "../util"; + +describe("buildLoopbackClientId", () => { + test("builds client ID from localhost", () => { + const result = buildLoopbackClientId({ + hostname: "localhost", + pathname: "/", + port: "3000", + }); + expect(result).toBe( + "http://localhost?redirect_uri=" + + encodeURIComponent("http://127.0.0.1:3000/"), + ); + }); + + test("builds client ID from 127.0.0.1", () => { + const result = buildLoopbackClientId({ + hostname: "127.0.0.1", + pathname: "/", + port: "8080", + }); + expect(result).toBe( + "http://localhost?redirect_uri=" + + encodeURIComponent("http://127.0.0.1:8080/"), + ); + }); + + test("preserves non-root pathname in client ID", () => { + const result = buildLoopbackClientId({ + hostname: "localhost", + pathname: "/callback", + port: "3000", + }); + expect(result).toBe( + "http://localhost/callback?redirect_uri=" + + encodeURIComponent("http://127.0.0.1:3000/callback"), + ); + }); + + test("omits port from redirect_uri when empty", () => { + const result = buildLoopbackClientId({ + hostname: "localhost", + pathname: "/", + port: "", + }); + expect(result).toBe( + "http://localhost?redirect_uri=" + + encodeURIComponent("http://127.0.0.1/"), + ); + }); + + test("accepts custom localhost override", () => { + const result = buildLoopbackClientId( + { hostname: "localhost", pathname: "/", port: "3000" }, + "::1", + ); + expect(result).toContain("http%3A%2F%2F%3A%3A1%3A3000"); + }); + + test("accepts [::1] hostname", () => { + const result = buildLoopbackClientId({ + hostname: "[::1]", + pathname: "/", + port: "3000", + }); + expect(result).toContain("redirect_uri="); + }); + + test("throws on non-loopback hostname", () => { + expect(() => + buildLoopbackClientId({ + hostname: "example.com", + pathname: "/", + port: "3000", + }), + ).toThrow(TypeError); + }); + + test("throws with descriptive message", () => { + expect(() => + buildLoopbackClientId({ + hostname: "evil.com", + pathname: "/", + port: "80", + }), + ).toThrow('Expected a loopback hostname, got "evil.com"'); + }); +}); diff --git a/packages/oauth-client-browser/src/browser-client.ts b/packages/oauth-client-browser/src/browser-client.ts index 740cbc8..ae0ce3b 100644 --- a/packages/oauth-client-browser/src/browser-client.ts +++ b/packages/oauth-client-browser/src/browser-client.ts @@ -7,8 +7,10 @@ import { LAST_ACTIVE_KEY, importJwk, InvalidStateError, + OAuthCallbackError, ResolutionError, TokenExchangeError, + type SessionEventHooks, type StorageAdapter, } from "@happyview/oauth-client"; import { LocalStorageAdapter } from "./local-storage-adapter"; @@ -31,6 +33,7 @@ export interface HappyViewBrowserClientOptions { redirectUri?: string; scopes?: string; storage?: StorageAdapter; + sessionHooks?: SessionEventHooks; fetch?: typeof globalThis.fetch; } @@ -47,8 +50,21 @@ interface PendingAuthState { } export interface LoginOptions { + 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 PopupLoginOptions extends LoginOptions { @@ -77,8 +93,8 @@ interface AuthServerMetadata { } export class HappyViewBrowserClient extends HappyViewOAuthClient { - private readonly handleResolver: AtprotoDohHandleResolver; - private readonly didResolver: DidResolverCommon; + readonly handleResolver: AtprotoDohHandleResolver; + readonly didResolver: DidResolverCommon; private readonly clientId: string; private readonly redirectUri: string | undefined; private readonly scopes: string; @@ -89,6 +105,7 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { instanceUrl: options.instanceUrl, clientKey: options.clientKey, storage: storageAdapter, + sessionHooks: options.sessionHooks, fetch: fetchFn, }); @@ -114,7 +131,7 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { const pdsUrl = extractPdsUrl(didDoc); const authMeta = await this.fetchAuthServerMetadata(pdsUrl); - const scopes = options?.scopes ?? this.scopes; + const scopes = options?.scope ?? options?.scopes ?? this.scopes; // Provision DPoP key from HappyView const { provisionId, rawJwk, pkceVerifier: provisionPkceVerifier } = @@ -142,7 +159,8 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { JSON.stringify(pendingState), ); - const { clientId, redirectUri } = this.resolveOAuthEndpoints(); + const { clientId, redirectUri: defaultRedirectUri } = this.resolveOAuthEndpoints(); + const redirectUri = options?.redirect_uri ?? defaultRedirectUri; const authParams = new URLSearchParams({ response_type: "code", @@ -155,6 +173,16 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { 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)); + // ATProto requires Pushed Authorization Requests (PAR) const parEndpoint = authMeta.pushed_authorization_request_endpoint; if (parEndpoint) { @@ -200,121 +228,128 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { const code = params.get("code"); const state = params.get("state"); - if (!code || !state) { - const error = params.get("error"); - const errorDesc = params.get("error_description"); - const raw = search ?? window.location.search; - throw new InvalidStateError( - `Missing code or state in callback URL. ` + - `error=${error}, error_description=${errorDesc}, ` + - `search=${raw}` - ); + if (!state) { + throw new OAuthCallbackError(params, 'Missing "state" parameter'); } const pendingJson = await this.storage.get(`pending-auth:${state}`); if (!pendingJson) { - throw new InvalidStateError( - "No pending auth state found for this callback", + throw new OAuthCallbackError( + params, + `Unknown authorization session "${state}"`, + state, ); } - const pending: PendingAuthState = JSON.parse(pendingJson); - // Import the stored JWK into a Key for DPoP proof generation - const dpopKey = await importJwk(pending.rawJwk); - // Build a plain public JWK object from the raw key (strip private "d" component) - const { d: _, ...publicJwk } = pending.rawJwk; + if (params.has("error")) { + await this.storage.delete(`pending-auth:${state}`); + throw new OAuthCallbackError(params, undefined, state); + } - const { clientId, redirectUri } = this.resolveOAuthEndpoints(); + if (!code) { + throw new OAuthCallbackError( + params, + 'Missing "code" parameter', + state, + ); + } - // Token exchange with DPoP nonce handling — the PDS may require a nonce - // by responding with 400 + use_dpop_nonce error and a DPoP-Nonce header. - let dpopNonce: string | undefined; - let tokenResp!: Response; + const pending: PendingAuthState = JSON.parse(pendingJson); - 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 } : {}), - }, - ); + try { + const dpopKey = await importJwk(pending.rawJwk); + const { d: _, ...publicJwk } = pending.rawJwk; + + const { clientId, redirectUri } = this.resolveOAuthEndpoints(); + + 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: clientId, - code_verifier: pending.authPkceVerifier, - }), - }); + 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: clientId, + code_verifier: pending.authPkceVerifier, + }), + }); - // If the server requires a DPoP nonce, retry with it - 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; + 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, + ); } - // Not a nonce error — throw - throw new TokenExchangeError( - `Token exchange failed: ${tokenResp.status} ${errorBody}`, - tokenResp.status, - errorBody, - ); } - } - break; - } + break; + } - if (!tokenResp!.ok) { - const err = await tokenResp!.text(); - throw new TokenExchangeError( - `Token exchange failed: ${tokenResp!.status} ${err}`, - tokenResp!.status, - err, - ); - } + 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 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, - }); + 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}`); + await this.storage.delete(`pending-auth:${state}`); - return session; + return session; + } catch (err) { + throw OAuthCallbackError.from(err, params, state); + } } async logout(did: string): Promise { @@ -325,7 +360,7 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { return this.logout(did); } - override async restore(did?: string): Promise { + override async restore(did?: string, _refresh?: boolean | "auto"): Promise { if (did) { const session = await this.restoreSession(did); if (session) { @@ -497,6 +532,10 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { // No persistent resources to clean up } + async [Symbol.asyncDispose](): Promise { + this.dispose(); + } + private resolveOAuthEndpoints(): { clientId: string; redirectUri: string } { return { clientId: this.clientId, diff --git a/packages/oauth-client-browser/src/index.ts b/packages/oauth-client-browser/src/index.ts index b581a9f..4c20236 100644 --- a/packages/oauth-client-browser/src/index.ts +++ b/packages/oauth-client-browser/src/index.ts @@ -1,19 +1,6 @@ -export { - ApiError, - AuthenticationError, - HappyViewError, - HappyViewSession, - InvalidStateError, - MemoryStorage, - ResolutionError, - TokenExchangeError, - importJwk, - type DpopProvision, - type HappyViewOAuthClientOptions, - type RegisterSessionParams, - type StorageAdapter, - type StoredSession, -} from "@happyview/oauth-client"; +export * from "@happyview/oauth-client"; +export * from "@atproto-labs/handle-resolver"; +export * from "@atproto-labs/did-resolver"; export { HappyViewBrowserClient, @@ -27,3 +14,4 @@ export type { SignInOptions, } from "./browser-client"; export { LocalStorageAdapter } from "./local-storage-adapter"; +export { buildLoopbackClientId } from "./util"; diff --git a/packages/oauth-client-browser/src/util.ts b/packages/oauth-client-browser/src/util.ts new file mode 100644 index 0000000..a98d455 --- /dev/null +++ b/packages/oauth-client-browser/src/util.ts @@ -0,0 +1,19 @@ +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +export function buildLoopbackClientId( + location: { hostname: string; pathname: string; port: string }, + localhost = "127.0.0.1", +): string { + if (!LOOPBACK_HOSTS.has(location.hostname)) { + throw new TypeError( + `Expected a loopback hostname, got "${location.hostname}"`, + ); + } + + const port = location.port ? `:${location.port}` : ""; + const redirectUri = `http://${localhost}${port}${location.pathname}`; + + const pathname = location.pathname === "/" ? "" : location.pathname; + const encodedRedirect = encodeURIComponent(redirectUri); + return `http://localhost${pathname}?redirect_uri=${encodedRedirect}`; +} diff --git a/packages/oauth-client-browser/tsconfig.json b/packages/oauth-client-browser/tsconfig.json index 51fb261..40083db 100644 --- a/packages/oauth-client-browser/tsconfig.json +++ b/packages/oauth-client-browser/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "./dist", "rootDir": "./src", - "lib": ["ES2022", "DOM", "DOM.Iterable"] + "lib": ["ES2022", "ESNext.Disposable", "DOM", "DOM.Iterable"] }, "include": ["src"], "exclude": ["src/__tests__"] diff --git a/packages/oauth-client/src/client.ts b/packages/oauth-client/src/client.ts index ba753bb..0e37d71 100644 --- a/packages/oauth-client/src/client.ts +++ b/packages/oauth-client/src/client.ts @@ -8,6 +8,7 @@ import type { ProvisionKeyResponse, RegisterSessionParams, RegisterSessionResponse, + SessionEventHooks, StorageAdapter, StoredSession, } from "./types"; @@ -15,12 +16,38 @@ import type { const STORAGE_PREFIX = "happyview:session:"; export const LAST_ACTIVE_KEY = "happyview:last-active-did"; +export interface FetchMetadataOptions { + clientId: string; + fetch?: typeof globalThis.fetch; + signal?: AbortSignal; +} + export class HappyViewOAuthClient { + static async fetchMetadata({ + clientId, + fetch: fetchFn = globalThis.fetch, + signal, + }: FetchMetadataOptions): Promise> { + signal?.throwIfAborted(); + const resp = await fetchFn(clientId, { redirect: "error", signal }); + if (resp.status !== 200) { + resp.body?.cancel?.(); + throw new Error(`Failed to fetch client metadata: ${resp.status}`); + } + const mime = resp.headers.get("content-type")?.split(";")[0].trim(); + if (mime !== "application/json") { + resp.body?.cancel?.(); + throw new Error(`Invalid client metadata content type: ${mime}`); + } + return resp.json() as Promise>; + } + protected readonly instanceUrl: string; protected readonly clientKey: string; protected readonly storage: StorageAdapter; private readonly clientSecret: string | undefined; protected readonly _fetch: typeof globalThis.fetch; + private readonly sessionHooks: SessionEventHooks; constructor( options: HappyViewOAuthClientOptions & { @@ -32,6 +59,7 @@ export class HappyViewOAuthClient { this.clientSecret = options.clientSecret; this.storage = options.storage ?? new MemoryStorage(); this._fetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch; + this.sessionHooks = options.sessionHooks ?? {}; } get isConfidential(): boolean { @@ -135,6 +163,9 @@ export class HappyViewOAuthClient { accessToken: params.accessToken, clientKey: this.clientKey, instanceUrl: this.instanceUrl, + scopes: params.scopes, + pdsUrl: params.pdsUrl, + issuer: params.issuer, }; await this.storage.set( `${STORAGE_PREFIX}${data.did}`, @@ -142,14 +173,22 @@ export class HappyViewOAuthClient { ); await this.storage.set(LAST_ACTIVE_KEY, data.did); - return new HappyViewSession({ + const session = new HappyViewSession({ did: data.did, dpopKey, accessToken: params.accessToken, clientKey: this.clientKey, instanceUrl: this.instanceUrl, + scopes: params.scopes, + pdsUrl: params.pdsUrl, + issuer: params.issuer, fetch: this._fetch, + onSignOut: () => this.deleteSession(data.did), }); + + this.sessionHooks.onSessionUpdate?.(data.did); + + return session; } async deleteSession(did: string): Promise { @@ -176,6 +215,8 @@ export class HappyViewOAuthClient { if (lastActive === did) { await this.storage.delete(LAST_ACTIVE_KEY); } + + this.sessionHooks.onSessionDelete?.(did); } async restoreSession(did: string): Promise { @@ -190,7 +231,11 @@ export class HappyViewOAuthClient { accessToken: data.accessToken, clientKey: data.clientKey, instanceUrl: data.instanceUrl, + scopes: data.scopes, + pdsUrl: data.pdsUrl, + issuer: data.issuer, fetch: this._fetch, + onSignOut: () => this.deleteSession(data.did), }); } diff --git a/packages/oauth-client/src/errors.ts b/packages/oauth-client/src/errors.ts index e78b83e..2a69e26 100644 --- a/packages/oauth-client/src/errors.ts +++ b/packages/oauth-client/src/errors.ts @@ -52,3 +52,37 @@ export class ResolutionError extends HappyViewError { this.name = "ResolutionError"; } } + +export class OAuthCallbackError extends HappyViewError { + readonly params: URLSearchParams; + readonly state: string | undefined; + + static from( + err: unknown, + params: URLSearchParams, + state?: string, + ): OAuthCallbackError { + if (err instanceof OAuthCallbackError) return err; + const message = err instanceof Error ? err.message : undefined; + return new OAuthCallbackError(params, message, state, err); + } + + constructor( + params: URLSearchParams, + message?: string, + state?: string, + cause?: unknown, + ) { + super( + message ?? + params.get("error_description") ?? + "OAuth callback error", + ); + this.name = "OAuthCallbackError"; + this.params = params; + this.state = state; + if (cause !== undefined) { + this.cause = cause; + } + } +} diff --git a/packages/oauth-client/src/index.ts b/packages/oauth-client/src/index.ts index 1275cba..f7646c3 100644 --- a/packages/oauth-client/src/index.ts +++ b/packages/oauth-client/src/index.ts @@ -1,10 +1,15 @@ +export * from "@atproto/jwk"; +export * from "@atproto/jwk-webcrypto"; + export { HappyViewOAuthClient, LAST_ACTIVE_KEY } from "./client"; +export type { FetchMetadataOptions } from "./client"; export { importJwk } from "./import-jwk"; export { ApiError, AuthenticationError, HappyViewError, InvalidStateError, + OAuthCallbackError, ResolutionError, TokenExchangeError, } from "./errors"; @@ -17,6 +22,8 @@ export type { ProvisionKeyResponse, RegisterSessionParams, RegisterSessionResponse, + SessionEventHooks, StorageAdapter, StoredSession, + TokenInfo, } from "./types"; diff --git a/packages/oauth-client/src/session.ts b/packages/oauth-client/src/session.ts index 564ed72..8f33482 100644 --- a/packages/oauth-client/src/session.ts +++ b/packages/oauth-client/src/session.ts @@ -1,4 +1,5 @@ import type { Key } from "@atproto/jwk"; +import type { TokenInfo } from "./types"; export interface HappyViewSessionOptions { did: string; @@ -6,7 +7,11 @@ export interface HappyViewSessionOptions { accessToken: string; clientKey: string; instanceUrl: string; + scopes?: string; + pdsUrl?: string; + issuer?: string; fetch?: typeof globalThis.fetch; + onSignOut?: () => Promise; } function randomHex(byteLength: number): string { @@ -33,7 +38,11 @@ export class HappyViewSession { private readonly accessToken: string; private readonly clientKey: string; private readonly instanceUrl: string; + private readonly _scopes: string | undefined; + private readonly _pdsUrl: string | undefined; + private readonly _issuer: string | undefined; private readonly _fetch: typeof globalThis.fetch; + private readonly _onSignOut: (() => Promise) | undefined; private dpopNonce: string | undefined; constructor(options: HappyViewSessionOptions) { @@ -42,7 +51,30 @@ export class HappyViewSession { this.accessToken = options.accessToken; this.clientKey = options.clientKey; this.instanceUrl = options.instanceUrl.replace(/\/+$/, ""); + this._scopes = options.scopes; + this._pdsUrl = options.pdsUrl; + this._issuer = options.issuer; this._fetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch; + this._onSignOut = options.onSignOut; + } + + get sub(): string { + return this.did; + } + + getTokenInfo(): TokenInfo { + return { + scope: this._scopes, + iss: this._issuer, + aud: this._pdsUrl, + sub: this.did, + }; + } + + async signOut(): Promise { + if (this._onSignOut) { + await this._onSignOut(); + } } async fetchHandler(url: string, init: RequestInit): Promise { diff --git a/packages/oauth-client/src/types.ts b/packages/oauth-client/src/types.ts index c3a0dc9..4ea19cf 100644 --- a/packages/oauth-client/src/types.ts +++ b/packages/oauth-client/src/types.ts @@ -4,11 +4,17 @@ export interface StorageAdapter { delete(key: string): Promise; } +export interface SessionEventHooks { + onSessionUpdate?: (did: string) => void; + onSessionDelete?: (did: string, cause?: unknown) => void; +} + export interface HappyViewOAuthClientOptions { instanceUrl: string; clientKey: string; clientSecret?: string; storage?: StorageAdapter; + sessionHooks?: SessionEventHooks; } export interface DpopProvision { @@ -40,6 +46,18 @@ export interface StoredSession { accessToken: string; clientKey: string; instanceUrl: string; + scopes?: string; + pdsUrl?: string; + issuer?: string; +} + +export interface TokenInfo { + expiresAt?: Date; + expired?: boolean; + scope?: string; + iss?: string; + aud?: string; + sub: string; } export interface ProvisionKeyResponse { -- 2.51.2