From e76acbea356b453910ffdc6eaac78b42bfd2fd74 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 24 Apr 2026 20:08:57 -0500 Subject: [PATCH] feat(sdk): allow client to configure clientId --- packages/docs/docs/sdk/lex-agent.md | 1 + .../docs/docs/sdk/oauth-client-browser.md | 46 +++++++++- packages/docs/docs/sdk/overview.md | 9 +- .../src/__tests__/browser-client.test.ts | 85 +++++++++++++------ .../src/browser-client.ts | 23 ++--- 5 files changed, 118 insertions(+), 46 deletions(-) diff --git a/packages/docs/docs/sdk/lex-agent.md b/packages/docs/docs/sdk/lex-agent.md index 392e3c0..cd6ad43 100644 --- a/packages/docs/docs/sdk/lex-agent.md +++ b/packages/docs/docs/sdk/lex-agent.md @@ -21,6 +21,7 @@ import { createAgent } from "@happyview/lex-agent"; const client = new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", clientKey: "hvc_your_client_key", }); diff --git a/packages/docs/docs/sdk/oauth-client-browser.md b/packages/docs/docs/sdk/oauth-client-browser.md index 2746ca8..59a6fa6 100644 --- a/packages/docs/docs/sdk/oauth-client-browser.md +++ b/packages/docs/docs/sdk/oauth-client-browser.md @@ -17,17 +17,28 @@ import { HappyViewBrowserClient } from "@happyview/oauth-client-browser"; const client = new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", clientKey: "hvc_your_client_key", }); ``` -The client uses Web Crypto and localStorage by default. You can override either: +| 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` | 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 | +| `fetch` | No | Custom fetch implementation | + +The client uses localStorage by default. You can override it: ```typescript const client = new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", clientKey: "hvc_your_client_key", - crypto: myCustomCryptoAdapter, storage: myCustomStorageAdapter, }); ``` @@ -129,6 +140,37 @@ const pdsUrl = resolvePdsUrl(doc); const authMeta = await resolveAuthServerMetadata(pdsUrl); ``` +## 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. + +Example for a Next.js app: + +```typescript +// src/app/oauth-client-metadata.json/route.ts +import { type NextRequest } from "next/server"; + +export function GET(request: NextRequest) { + const origin = request.nextUrl.origin; + + return Response.json({ + client_id: `${origin}/oauth-client-metadata.json`, + client_name: "My App", + client_uri: origin, + redirect_uris: [`${origin}/oauth/callback`], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + scope: "atproto", + application_type: "web", + dpop_bound_access_tokens: true, + }); +} +``` + +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`). + ## 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: diff --git a/packages/docs/docs/sdk/overview.md b/packages/docs/docs/sdk/overview.md index 1fbe5b8..39f53e1 100644 --- a/packages/docs/docs/sdk/overview.md +++ b/packages/docs/docs/sdk/overview.md @@ -2,11 +2,11 @@ HappyView provides JavaScript packages for building third-party apps that authenticate with a HappyView instance and make XRPC requests on behalf of users. -| Package | Purpose | -| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Package | Purpose | +| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | [`@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`](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` | ## Which package do I need? @@ -39,6 +39,7 @@ import { createAgent } from "@happyview/lex-agent"; // Set up the OAuth client const oauthClient = new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", clientKey: "hvc_your_client_key", }); 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 56e5ab9..3f9cf3e 100644 --- a/packages/oauth-client-browser/src/__tests__/browser-client.test.ts +++ b/packages/oauth-client-browser/src/__tests__/browser-client.test.ts @@ -26,6 +26,7 @@ afterEach(() => { function createClient(fetchFn?: typeof globalThis.fetch) { return new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", clientKey: "hvc_test", storage: new LocalStorageAdapter(), fetch: fetchFn, @@ -40,7 +41,14 @@ function mockFetchForFullFlow() { return new Response( JSON.stringify({ Status: 0, - Answer: [{ name: "_atproto.user.bsky.social.", type: 16, TTL: 300, data: '"did=did:plc:abcdefghijklmnopqrstuvwx"' }], + Answer: [ + { + name: "_atproto.user.bsky.social.", + type: 16, + TTL: 300, + data: '"did=did:plc:abcdefghijklmnopqrstuvwx"', + }, + ], }), { status: 200, headers: { "content-type": "application/dns-json" } }, ); @@ -50,7 +58,13 @@ function mockFetchForFullFlow() { return new Response( JSON.stringify({ id: "did:plc:abcdefghijklmnopqrstuvwx", - service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }], + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://pds.example.com", + }, + ], }), { status: 200, headers: { "content-type": "application/json" } }, ); @@ -71,7 +85,8 @@ function mockFetchForFullFlow() { 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", + pushed_authorization_request_endpoint: + "https://pds.example.com/oauth/par", }), { status: 200 }, ); @@ -89,14 +104,20 @@ function mockFetchForFullFlow() { if (url.includes("/oauth/par")) { return new Response( - JSON.stringify({ request_uri: "urn:ietf:params:oauth:request_uri:test", expires_in: 60 }), + 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" }), + JSON.stringify({ + session_id: "sess_test", + did: "did:plc:abcdefghijklmnopqrstuvwx", + }), { status: 201 }, ); } @@ -123,6 +144,7 @@ describe("HappyViewBrowserClient", () => { test("constructor sets up LocalStorageAdapter by default", () => { const client = new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", clientKey: "hvc_test", }); expect(client).toBeDefined(); @@ -136,6 +158,7 @@ describe("HappyViewBrowserClient", () => { }; const client = new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", clientKey: "hvc_test", storage: customStorage, }); @@ -181,8 +204,8 @@ describe("HappyViewBrowserClient", () => { expect(session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); // Verify token exchange included DPoP proof header - const tokenCall = fetchFn.mock.calls.find( - (call: any[]) => String(call[0]).includes("/oauth/token"), + const tokenCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/token"), ); expect(tokenCall).toBeDefined(); const tokenInit = tokenCall![1] as RequestInit; @@ -246,11 +269,13 @@ describe("HappyViewBrowserClient", () => { await client.callback("?code=auth-code&state=state789"); - const tokenCall = fetchFn.mock.calls.find( - (call: any[]) => String(call[0]).includes("/oauth/token"), + const tokenCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/token"), ); expect(tokenCall).toBeDefined(); - const body = new URLSearchParams((tokenCall![1] as RequestInit).body as string); + const body = new URLSearchParams( + (tokenCall![1] as RequestInit).body as string, + ); expect(body.get("code_verifier")).toBe("auth-verifier"); }); @@ -308,13 +333,17 @@ describe("HappyViewBrowserClient", () => { await client.callback("?code=auth-code&state=stateathtest"); - const tokenCall = fetchFn.mock.calls.find( - (call: any[]) => String(call[0]).includes("/oauth/token"), + const tokenCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/token"), ); - const dpopJwt = new Headers((tokenCall![1] as RequestInit).headers).get("dpop")!; + const dpopJwt = new Headers((tokenCall![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, "/"))); + const payload = JSON.parse( + atob(padded.replace(/-/g, "+").replace(/_/g, "/")), + ); expect(payload.ath).toBeUndefined(); expect(payload.htm).toBe("POST"); expect(payload.htu).toBe("https://pds.example.com/oauth/token"); @@ -341,13 +370,15 @@ describe("HappyViewBrowserClient", () => { }); test("callback throws TokenExchangeError on token endpoint 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 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); @@ -428,15 +459,19 @@ describe("HappyViewBrowserClient", () => { ); // Mock the DELETE response - const deleteFn = mock(async (input: RequestInfo | URL, init?: RequestInit) => { - return new Response(null, { status: 204 }); - }); + const deleteFn = mock( + async (input: RequestInfo | URL, init?: RequestInit) => { + return new Response(null, { status: 204 }); + }, + ); const logoutClient = createClient(deleteFn); await logoutClient.logout("did:plc:abcdefghijklmnopqrstuvwx"); expect( - localStorage.getItem("@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)"), + localStorage.getItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + ), ).toBeNull(); expect( localStorage.getItem("@happyview/oauth(happyview:last-active-did)"), diff --git a/packages/oauth-client-browser/src/browser-client.ts b/packages/oauth-client-browser/src/browser-client.ts index 9009c82..86b47db 100644 --- a/packages/oauth-client-browser/src/browser-client.ts +++ b/packages/oauth-client-browser/src/browser-client.ts @@ -14,7 +14,9 @@ import { LocalStorageAdapter } from "./local-storage-adapter"; export interface HappyViewBrowserClientOptions { instanceUrl: string; + clientId: string; clientKey: string; + redirectUri?: string; scopes?: string; storage?: StorageAdapter; fetch?: typeof globalThis.fetch; @@ -49,6 +51,8 @@ interface AuthServerMetadata { export class HappyViewBrowserClient extends HappyViewOAuthClient { private readonly handleResolver: AtprotoDohHandleResolver; private readonly didResolver: DidResolverCommon; + private readonly clientId: string; + private readonly redirectUri: string | undefined; private readonly scopes: string; constructor(options: HappyViewBrowserClientOptions) { const fetchFn = options.fetch ?? (((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch); @@ -60,6 +64,8 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { fetch: fetchFn, }); + this.clientId = options.clientId; + this.redirectUri = options.redirectUri; this.scopes = options.scopes ?? "atproto"; this.handleResolver = new AtprotoDohHandleResolver({ dohEndpoint: "https://dns.google/resolve", @@ -289,22 +295,9 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { } private resolveOAuthEndpoints(): { clientId: string; redirectUri: string } { - const isLoopback = - window.location.hostname === "127.0.0.1" || - window.location.hostname === "[::1]" || - window.location.hostname === "localhost"; - - if (isLoopback) { - const params = new URLSearchParams({ scope: this.scopes }); - return { - clientId: `http://localhost?${params}`, - redirectUri: `http://127.0.0.1:${window.location.port}/`, - }; - } - return { - clientId: `${this.instanceUrl}/oauth-client-metadata.json`, - redirectUri: `${window.location.origin}/oauth/callback`, + clientId: this.clientId, + redirectUri: this.redirectUri ?? `${window.location.origin}/oauth/callback`, }; } -- 2.51.2