From 7b87c36ffd9458942c321009b4a39013abb9cff7 Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 05 May 2026 04:30:14 +0000 Subject: [PATCH] feat: update JS SDKs to more closely match their @atproto cousins --- packages/docs/docs/getting-started/authentication.md | 9 +++++---- packages/docs/docs/guides/features/api-clients.md | 28 +++++++++++----------------- packages/docs/docs/sdk/lex-agent.md | 5 +++-- packages/docs/docs/sdk/oauth-client-browser.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------ packages/docs/docs/sdk/overview.md | 9 +++++---- packages/lex-agent/README.md | 3 ++- packages/oauth-client-browser/README.md | 34 +++++++++++++++++----------------- packages/oauth-client-browser/src/__tests__/browser-client.test.ts | 306 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- packages/oauth-client-browser/src/browser-client.ts | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- packages/oauth-client-browser/src/index.ts | 8 +++++++- packages/oauth-client/src/client.ts | 2 +- packages/oauth-client/src/index.ts | 2 +- 12 file(s) changed, 668 insertion(s)(+), 81 deletion(s)(-) diff --git a/packages/docs/docs/getting-started/authentication.md b/packages/docs/docs/getting-started/authentication.md --- a/packages/docs/docs/getting-started/authentication.md +++ b/packages/docs/docs/getting-started/authentication.md @@ -67,11 +67,12 @@ instanceUrl: "https://happyview.example.com", clientKey: "hvc_your_client_key", }); -// Login — redirects to the user's PDS for authorization -await oauthClient.login("alice.bsky.social"); +// Sign in — redirects to the user's PDS for authorization +await oauthClient.signIn("alice.bsky.social"); -// On /oauth/callback — complete the token exchange -const session = await oauthClient.callback(); +// On page load — restore session or process OAuth callback +const result = await oauthClient.init(); +const session = result?.session; // Create a type-safe Lex client const agent = createAgent(session); diff --git a/packages/docs/docs/guides/features/api-clients.md b/packages/docs/docs/guides/features/api-clients.md --- a/packages/docs/docs/guides/features/api-clients.md +++ b/packages/docs/docs/guides/features/api-clients.md @@ -116,28 +116,22 @@ instanceUrl: "https://happyview.example.com", clientKey: "hvc_your_client_key", }); -// Login — redirects to the user's PDS -await client.login("alice.bsky.social"); +// Sign in — redirects to the user's PDS +await client.signIn("alice.bsky.social"); ``` -On your callback page: +On page load, restore a session or process the OAuth callback: ```typescript -const session = await client.callback(); - -// Make authenticated requests -const response = await session.fetchHandler( - "/xrpc/com.example.getStuff?limit=10", - { method: "GET" }, -); -``` +const result = await client.init(); +if (result) { + const { session } = result; -On subsequent page loads, restore the session from localStorage: - -```typescript -const session = await client.restore(); -if (session) { - // User is still logged in + // Make authenticated requests + const response = await session.fetchHandler( + "/xrpc/com.example.getStuff?limit=10", + { method: "GET" }, + ); } ``` diff --git a/packages/docs/docs/sdk/lex-agent.md b/packages/docs/docs/sdk/lex-agent.md --- a/packages/docs/docs/sdk/lex-agent.md +++ b/packages/docs/docs/sdk/lex-agent.md @@ -25,8 +25,9 @@ clientId: "https://example.com/oauth-client-metadata.json", clientKey: "hvc_your_client_key", }); -// Authenticate (or restore a session) -const session = await client.restore(); +// Restore an existing session +const result = await client.init(); +const session = result?.session; // Create a Lex agent from the session const agent = createAgent(session); diff --git a/packages/docs/docs/sdk/oauth-client-browser.md b/packages/docs/docs/sdk/oauth-client-browser.md --- a/packages/docs/docs/sdk/oauth-client-browser.md +++ b/packages/docs/docs/sdk/oauth-client-browser.md @@ -47,26 +47,45 @@ :::note The API client must be registered as a **public** client (no secret) with your app's origin in `allowed_origins`. See [Authentication — API clients](../getting-started/authentication.md#api-clients-confidential-vs-public). ::: -## Login +## Sign in -`login()` resolves the user's handle, discovers their PDS, provisions a DPoP key, and redirects the browser to the PDS authorization server: +`signIn()` resolves the user's handle, discovers their PDS, provisions a DPoP key, and redirects the browser to the PDS authorization server: ```typescript -await client.login("alice.bsky.social"); +await client.signIn("alice.bsky.social"); // Browser redirects — code stops here ``` -If you need the authorization URL without redirecting (e.g., for a popup or custom UI), use `prepareLogin()`: +To sign in via a popup window instead: + +```typescript +const session = await client.signIn("alice.bsky.social", { + display: "popup", +}); +``` + +Or use the explicit methods: + +```typescript +// Full-page redirect (equivalent to signIn without display option) +await client.signInRedirect("alice.bsky.social"); + +// Popup window +const session = await client.signInPopup("alice.bsky.social"); +``` + +If you need the authorization URL without redirecting (e.g., for a custom UI), use `prepareLogin()`: ```typescript const { authorizationUrl, did, state } = await client.prepareLogin("alice.bsky.social"); +``` -// Open in a popup, new tab, etc. -window.open(authorizationUrl); -``` +:::note +`login()` still works as an alias for `signInRedirect()`. +::: -### What happens during login +### What happens during sign in 1. The handle is resolved to a DID via `resolveHandleToDid`. 2. The DID document is fetched to find the PDS URL. @@ -74,32 +93,60 @@ 3. The PDS's OAuth authorization server metadata is fetched. 4. A DPoP key is provisioned from HappyView. 5. PKCE challenge/verifier pairs are generated (one for HappyView's DPoP provisioning, one for the PDS authorization server). 6. The pending auth state is stored in localStorage. -7. The browser is redirected to the PDS authorization endpoint. +7. The browser is redirected to the PDS authorization endpoint (or a popup is opened). + +## Initialization + +On page load, call `init()` to automatically handle both session restoration and OAuth callbacks: + +```typescript +const result = await client.init(); +if (result) { + const { session, state } = result; + // session is ready to use +} +``` + +`init()` checks the URL for OAuth callback parameters. If found, it processes the callback and returns `{ session, state }`. Otherwise, it tries to restore the last active session from localStorage. + +For more control, use the specific methods: + +```typescript +// Restore only — ignores callback params in the URL +const result = await client.initRestore(); +if (result) { + const { session } = result; +} + +// Callback only — throws if no callback params are present +const { session, state } = await client.initCallback(); +``` -## OAuth callback +### Restoring a specific session -Your app needs an `/oauth/callback` route. On that page, call `callback()` to complete the token exchange: +To restore a specific user's session by DID: ```typescript -// On /oauth/callback -const session = await client.callback(); -// Session is now stored in localStorage and ready to use +const session = await client.restore("did:plc:abc123"); ``` -`callback()` reads the `code` and `state` from the URL query string, exchanges the code for tokens at the PDS token endpoint, and registers the session with HappyView. The pending auth state is cleaned up automatically. +Calling `restore()` with no arguments returns the last active session, or `null` if none is found. + +:::note +`callback()` still works as a standalone method that processes the OAuth callback and returns a session directly. +::: -## Restore session +## Detecting callback params -On subsequent page loads, restore the session from localStorage instead of re-authenticating: +`readCallbackParams()` checks the current URL for OAuth callback parameters without processing them. This is useful when your app uses client-side routing and needs to detect callbacks before the router changes the URL: ```typescript -const session = await client.restore(); -if (session) { - // User is still logged in +const params = client.readCallbackParams(); +if (params) { + // URL contains OAuth callback params — process them + const { session } = await client.initCallback(); } ``` - -Returns `null` if no stored session is found. ## Authenticated requests @@ -116,11 +163,15 @@ ``` Pass a relative path (prepends the HappyView instance URL) or a full URL (used as-is). -## Logout +## Revoke session ```typescript -await client.logout(session.did); +await client.revoke(session.did); ``` + +:::note +`logout()` still works as an alias for `revoke()`. +::: ## Resolution utilities diff --git a/packages/docs/docs/sdk/overview.md b/packages/docs/docs/sdk/overview.md --- a/packages/docs/docs/sdk/overview.md +++ b/packages/docs/docs/sdk/overview.md @@ -43,11 +43,12 @@ clientId: "https://example.com/oauth-client-metadata.json", clientKey: "hvc_your_client_key", }); -// Login — redirects to the user's PDS -await oauthClient.login("alice.bsky.social"); +// Sign in — redirects to the user's PDS +await oauthClient.signIn("alice.bsky.social"); -// On /oauth/callback — complete the flow -const session = await oauthClient.callback(); +// On page load — restore session or process callback +const result = await oauthClient.init(); +const session = result?.session; // Create a type-safe Lex client const agent = createAgent(session); diff --git a/packages/lex-agent/README.md b/packages/lex-agent/README.md --- a/packages/lex-agent/README.md +++ b/packages/lex-agent/README.md @@ -24,7 +24,8 @@ const client = new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", clientKey: "hvc_your_client_key", }); -const session = await client.restore(); +const result = await client.init(); +const session = result?.session; // Create a Lex agent from the session const agent = createAgent(session); diff --git a/packages/oauth-client-browser/README.md b/packages/oauth-client-browser/README.md --- a/packages/oauth-client-browser/README.md +++ b/packages/oauth-client-browser/README.md @@ -23,39 +23,39 @@ clientKey: "hvc_your_client_key", }); ``` -### Login +### Sign In Redirects the user to their PDS authorization server: ```typescript -await client.login("alice.bsky.social"); +await client.signIn("alice.bsky.social"); // User is redirected to their PDS for authorization ``` -If you need the authorization URL without an immediate redirect (e.g., to open in a popup), use `prepareLogin`: +Or sign in via a popup: ```typescript -const { authorizationUrl, did, state } = - await client.prepareLogin("alice.bsky.social"); +const session = await client.signIn("alice.bsky.social", { + display: "popup", +}); ``` -### OAuth Callback - -On the `/oauth/callback` route, call `callback()` to complete the token exchange: +If you need the authorization URL without an immediate redirect, use `prepareLogin`: ```typescript -const session = await client.callback(); -// Session is now stored in localStorage +const { authorizationUrl, did, state } = + await client.prepareLogin("alice.bsky.social"); ``` -### Restore Session +### Initialization -On subsequent page loads, restore the session from localStorage: +On page load, call `init()` to restore a session or process an OAuth callback: ```typescript -const session = await client.restore(); -if (session) { - // User is still logged in +const result = await client.init(); +if (result) { + const { session } = result; + // User is logged in } ``` @@ -70,10 +70,10 @@ { method: "GET" }, ); ``` -### Logout +### Revoke Session ```typescript -await client.logout("did:plc:abc123"); +await client.revoke("did:plc:abc123"); ``` ## Exports diff --git a/packages/oauth-client-browser/src/__tests__/browser-client.test.ts b/packages/oauth-client-browser/src/__tests__/browser-client.test.ts --- a/packages/oauth-client-browser/src/__tests__/browser-client.test.ts +++ b/packages/oauth-client-browser/src/__tests__/browser-client.test.ts @@ -4,7 +4,10 @@ InvalidStateError, TokenExchangeError, type StorageAdapter, } from "@happyview/oauth-client"; -import { HappyViewBrowserClient } from "../browser-client"; +import { + HappyViewBrowserClient, + LoginContinuedInParentWindowError, +} from "../browser-client"; import { LocalStorageAdapter } from "../local-storage-adapter"; // Generate a real ES256 JWK once for all tests that need importJwk to succeed @@ -349,6 +352,56 @@ expect(payload.htm).toBe("POST"); expect(payload.htu).toBe("https://pds.example.com/oauth/token"); }); + test("prepareLogin uses constructor scopes by default", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + scopes: "atproto transition:generic", + storage: new LocalStorageAdapter(), + fetch: fetchFn, + }); + + await client.prepareLogin("user.bsky.social"); + + 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 accepts per-call scope override", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + scopes: "atproto", + storage: new LocalStorageAdapter(), + fetch: fetchFn, + }); + + await client.prepareLogin("user.bsky.social", { + scopes: "atproto transition:generic repo:app.example.post", + }); + + 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 repo:app.example.post", + ); + }); + test("callback throws InvalidStateError when code or state is missing", async () => { const client = createClient(); try { @@ -476,5 +529,256 @@ ).toBeNull(); expect( localStorage.getItem("@happyview/oauth(happyview:last-active-did)"), ).toBeNull(); + }); + + test("revoke is an alias for logout", 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", + ); + + await client.revoke("did:plc:abcdefghijklmnopqrstuvwx"); + + expect( + localStorage.getItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + ), + ).toBeNull(); + }); + + test("restore with no args returns last active session", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:last-active-did)", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + 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(); + expect(session).not.toBeNull(); + expect(session!.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + test("restore with DID arg returns that specific session", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:specific)", + JSON.stringify({ + did: "did:plc:specific", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore("did:plc:specific"); + expect(session).not.toBeNull(); + expect(session!.did).toBe("did:plc:specific"); + }); + + test("restore with DID arg updates last active DID", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:specific)", + JSON.stringify({ + did: "did:plc:specific", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + await client.restore("did:plc:specific"); + + expect( + localStorage.getItem("@happyview/oauth(happyview:last-active-did)"), + ).toBe("did:plc:specific"); + }); + + test("restore with DID arg does not update last active when session missing", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:last-active-did)", + "did:plc:original", + ); + + await client.restore("did:plc:nonexistent"); + + expect( + localStorage.getItem("@happyview/oauth(happyview:last-active-did)"), + ).toBe("did:plc:original"); + }); + + test("restore with DID arg returns null when session does not exist", async () => { + const client = createClient(); + const session = await client.restore("did:plc:nonexistent"); + expect(session).toBeNull(); + }); + + test("initRestore returns session wrapper when last active exists", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:last-active-did)", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + 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 result = await client.initRestore(); + expect(result).toBeDefined(); + expect(result!.session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + test("initRestore returns undefined when no session exists", async () => { + const client = createClient(); + const result = await client.initRestore(); + expect(result).toBeUndefined(); + }); + + test("initCallback processes callback and returns session with state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const pendingState = { + 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: "initcb_state", + issuer: "https://pds.example.com", + }; + localStorage.setItem( + "@happyview/oauth(pending-auth:initcb_state)", + JSON.stringify(pendingState), + ); + + const result = await client.initCallback( + "?code=auth-code&state=initcb_state", + ); + expect(result.session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(result.state).toBe("initcb_state"); + }); + + test("readCallbackParams returns null when no OAuth params in URL", () => { + const client = createClient(); + const params = client.readCallbackParams(); + expect(params).toBeNull(); + }); + + test("findRedirectUrl returns configured redirectUri", () => { + const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + redirectUri: "https://myapp.com/callback", + }); + expect(client.findRedirectUrl()).toBe("https://myapp.com/callback"); + }); + + test("findRedirectUrl returns default when no redirectUri configured", () => { + const client = createClient(); + expect(client.findRedirectUrl()).toBe( + `${window.location.origin}/oauth/callback`, + ); + }); + + test("signInRedirect delegates to login", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + // signInRedirect calls login which calls prepareLogin then sets window.location.href + // We can verify it hits the same fetch endpoints as prepareLogin + // Since window.location.href assignment doesn't work in tests, we just verify + // the PAR request was made (proving prepareLogin was called) + await client.signInRedirect("user.bsky.social"); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + expect(parCall).toBeDefined(); + }); + + test("signIn defaults to signInRedirect", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.signIn("user.bsky.social"); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + expect(parCall).toBeDefined(); + }); + + test("prepareLogin accepts custom state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const result = await client.prepareLogin("user.bsky.social", { + state: "custom-state-123", + }); + + expect(result.state).toBe("custom-state-123"); + + const stored = localStorage.getItem( + "@happyview/oauth(pending-auth:custom-state-123)", + ); + expect(stored).not.toBeNull(); + }); + + test("dispose does not throw", () => { + const client = createClient(); + expect(() => client.dispose()).not.toThrow(); + }); + + 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); }); }); diff --git a/packages/oauth-client-browser/src/browser-client.ts b/packages/oauth-client-browser/src/browser-client.ts --- a/packages/oauth-client-browser/src/browser-client.ts +++ b/packages/oauth-client-browser/src/browser-client.ts @@ -4,6 +4,7 @@ import type { DidDocument } from "@atproto/did"; import { HappyViewOAuthClient, HappyViewSession, + LAST_ACTIVE_KEY, importJwk, InvalidStateError, ResolutionError, @@ -11,6 +12,17 @@ TokenExchangeError, type StorageAdapter, } from "@happyview/oauth-client"; import { LocalStorageAdapter } from "./local-storage-adapter"; + +const NAMESPACE = "@happyview/oauth-client-browser"; +const POPUP_CHANNEL_NAME = `${NAMESPACE}(popup-channel)`; +const POPUP_STATE_PREFIX = `${NAMESPACE}(popup-state):`; + +export class LoginContinuedInParentWindowError extends Error { + constructor() { + super("Login continued in parent window"); + this.name = "LoginContinuedInParentWindowError"; + } +} export interface HappyViewBrowserClientOptions { instanceUrl: string; @@ -34,6 +46,22 @@ state: string; issuer: string; } +export interface LoginOptions { + scopes?: string; + state?: string; +} + +export interface PopupLoginOptions extends LoginOptions { + popupName?: string; + popupFeatures?: string; +} + +export interface SignInOptions extends LoginOptions { + display?: "popup" | "page"; + popupName?: string; + popupFeatures?: string; +} + export interface PrepareLoginResult { authorizationUrl: string; did: string; @@ -74,7 +102,7 @@ }); this.didResolver = new DidResolverCommon({ fetch: fetchFn }); } - async prepareLogin(handle: string): Promise { + async prepareLogin(handle: string, options?: LoginOptions): Promise { // Resolve handle → DID → DID document → PDS URL → auth server metadata const resolvedDid = await this.handleResolver.resolve(handle); if (!resolvedDid) { @@ -86,6 +114,8 @@ const didDoc = await this.didResolver.resolve(resolvedDid); const pdsUrl = extractPdsUrl(didDoc); const authMeta = await this.fetchAuthServerMetadata(pdsUrl); + const scopes = options?.scopes ?? this.scopes; + // Provision DPoP key from HappyView const { provisionId, rawJwk, pkceVerifier: provisionPkceVerifier } = await this.provisionDpopKey(); @@ -94,10 +124,7 @@ // Separate PKCE for the PDS authorization server const authPkceVerifier = generatePkceVerifier(); const authPkceChallenge = await computePkceChallenge(authPkceVerifier); - const stateBytes = crypto.getRandomValues(new Uint8Array(16)); - const state = Array.from(stateBytes, (b) => - b.toString(16).padStart(2, "0"), - ).join(""); + const state = options?.state ?? randomHex(16); const pendingState: PendingAuthState = { did, @@ -122,7 +149,7 @@ response_type: "code", client_id: clientId, redirect_uri: redirectUri, state, - scope: this.scopes, + scope: scopes, code_challenge: authPkceChallenge, code_challenge_method: "S256", login_hint: handle, @@ -163,8 +190,8 @@ return { authorizationUrl, did, state }; } - async login(handle: string): Promise { - const { authorizationUrl } = await this.prepareLogin(handle); + async login(handle: string, options?: LoginOptions): Promise { + const { authorizationUrl } = await this.prepareLogin(handle, options); window.location.href = authorizationUrl; } @@ -294,6 +321,182 @@ async logout(did: string): Promise { await this.deleteSession(did); } + async revoke(did: string): Promise { + return this.logout(did); + } + + override async restore(did?: string): Promise { + if (did) { + const session = await this.restoreSession(did); + if (session) { + await this.storage.set(LAST_ACTIVE_KEY, did); + } + return session; + } + return super.restore(); + } + + async init(): Promise< + | { session: HappyViewSession; state?: string | null } + | undefined + > { + const params = this.readCallbackParams(); + if (params) { + return this.initCallback(`?${params.toString()}`); + } + return this.initRestore(); + } + + async initRestore(): Promise<{ session: HappyViewSession } | undefined> { + const session = await this.restore(); + if (session) return { session }; + return undefined; + } + + async initCallback( + search?: string, + ): Promise<{ session: HappyViewSession; state: string | null }> { + const searchStr = search ?? window.location.search; + const params = new URLSearchParams(searchStr); + const state = params.get("state"); + + history.replaceState(null, "", window.location.pathname); + + const session = await this.callback(searchStr); + + if (state?.startsWith(POPUP_STATE_PREFIX)) { + const stateKey = state.slice(POPUP_STATE_PREFIX.length); + const received = await sendPopupResult(stateKey, { + status: "fulfilled", + value: session.did, + }); + if (!received) { + await this.logout(session.did); + } + window.close(); + throw new LoginContinuedInParentWindowError(); + } + + return { session, state }; + } + + async signIn( + handle: string, + options?: SignInOptions, + ): Promise { + if (options?.display === "popup") { + return this.signInPopup(handle, options); + } + return this.signInRedirect(handle, options); + } + + async signInRedirect( + handle: string, + options?: LoginOptions, + ): Promise { + return this.login(handle, options); + } + + async signInPopup( + handle: string, + options?: PopupLoginOptions, + ): Promise { + const popupTarget = options?.popupName ?? "_blank"; + const popupFeatures = + options?.popupFeatures ?? + "width=600,height=600,menubar=no,toolbar=no"; + + let popup = window.open("about:blank", popupTarget, popupFeatures); + + const stateKey = Math.random().toString(36).slice(2); + const result = await this.prepareLogin(handle, { + ...options, + state: `${POPUP_STATE_PREFIX}${stateKey}`, + }); + + if (popup) { + popup.location.href = result.authorizationUrl; + } else { + popup = window.open( + result.authorizationUrl, + popupTarget, + popupFeatures, + ); + } + popup?.focus(); + + return new Promise((resolve, reject) => { + const channel = new BroadcastChannel(POPUP_CHANNEL_NAME); + const cleanup = () => { + clearTimeout(timeout); + channel.removeEventListener("message", onMessage); + channel.close(); + popup?.close(); + }; + + const timeout = setTimeout(() => { + reject(new Error("Popup login timed out")); + cleanup(); + }, 5 * 60e3); + + const onMessage = async ({ data }: MessageEvent) => { + if (data.key !== stateKey) return; + if (!("result" in data)) return; + + channel.postMessage({ key: stateKey, ack: true }); + cleanup(); + + if (data.result.status === "fulfilled") { + const did = data.result.value as string; + try { + const session = await this.restoreSession(did); + if (session) { + resolve(session); + } else { + reject( + new Error( + "Failed to restore session after popup login", + ), + ); + } + } catch (err) { + reject(err); + await this.logout(did); + } + } else { + reject( + new Error( + data.result.reason?.message ?? "Popup login failed", + ), + ); + } + }; + + channel.addEventListener("message", onMessage); + }); + } + + readCallbackParams(): URLSearchParams | null { + const params = new URLSearchParams(window.location.search); + if ( + !params.has("state") || + !(params.has("code") || params.has("error")) + ) { + return null; + } + return params; + } + + findRedirectUrl(): string { + return ( + this.redirectUri ?? `${window.location.origin}/oauth/callback` + ); + } + + dispose(): void { + // No persistent resources to clean up + } + private resolveOAuthEndpoints(): { clientId: string; redirectUri: string } { return { clientId: this.clientId, @@ -371,6 +574,31 @@ return btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); +} + +function sendPopupResult( + key: string, + result: { + status: "fulfilled" | "rejected"; + value?: string; + reason?: { message: string }; + }, +): Promise { + const channel = new BroadcastChannel(POPUP_CHANNEL_NAME); + return new Promise((resolve) => { + const cleanup = (received: boolean) => { + clearTimeout(timer); + channel.removeEventListener("message", onMessage); + channel.close(); + resolve(received); + }; + const onMessage = ({ data }: MessageEvent) => { + if ("ack" in data && data.key === key) cleanup(true); + }; + channel.addEventListener("message", onMessage); + channel.postMessage({ key, result }); + const timer = setTimeout(() => cleanup(false), 500); + }); } async function computePkceChallenge(verifier: string): Promise { diff --git a/packages/oauth-client-browser/src/index.ts b/packages/oauth-client-browser/src/index.ts --- a/packages/oauth-client-browser/src/index.ts +++ b/packages/oauth-client-browser/src/index.ts @@ -15,9 +15,15 @@ type StorageAdapter, type StoredSession, } from "@happyview/oauth-client"; -export { HappyViewBrowserClient } from "./browser-client"; +export { + HappyViewBrowserClient, + LoginContinuedInParentWindowError, +} from "./browser-client"; export type { HappyViewBrowserClientOptions, + LoginOptions, + PopupLoginOptions, PrepareLoginResult, + SignInOptions, } from "./browser-client"; export { LocalStorageAdapter } from "./local-storage-adapter"; diff --git a/packages/oauth-client/src/client.ts b/packages/oauth-client/src/client.ts --- a/packages/oauth-client/src/client.ts +++ b/packages/oauth-client/src/client.ts @@ -13,7 +13,7 @@ StoredSession, } from "./types"; const STORAGE_PREFIX = "happyview:session:"; -const LAST_ACTIVE_KEY = "happyview:last-active-did"; +export const LAST_ACTIVE_KEY = "happyview:last-active-did"; export class HappyViewOAuthClient { protected readonly instanceUrl: string; diff --git a/packages/oauth-client/src/index.ts b/packages/oauth-client/src/index.ts --- a/packages/oauth-client/src/index.ts +++ b/packages/oauth-client/src/index.ts @@ -1,4 +1,4 @@ -export { HappyViewOAuthClient } from "./client"; +export { HappyViewOAuthClient, LAST_ACTIVE_KEY } from "./client"; export { importJwk } from "./import-jwk"; export { ApiError, -- tangled.sh