From 8d6da9e2468917546994953aa446c535f6abfcc6 Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 21 Apr 2026 14:14:45 -0500 Subject: [PATCH] fix(sdk): take advantage of prior art on crypto from bsky --- bun.lock | 21 +- packages/oauth-client-browser/package.json | 5 +- .../src/__tests__/browser-client.test.ts | 110 +++--- .../src/__tests__/resolve.test.ts | 129 ------- .../src/__tests__/web-crypto-adapter.test.ts | 119 ------- .../src/browser-client.ts | 326 ++++++++++++++---- packages/oauth-client-browser/src/index.ts | 12 +- packages/oauth-client-browser/src/resolve.ts | 98 ------ .../src/web-crypto-adapter.ts | 45 --- packages/oauth-client/package.json | 4 + .../oauth-client/src/__tests__/client.test.ts | 131 ++++--- .../src/__tests__/dpop-proof.test.ts | 217 ------------ .../src/__tests__/session.test.ts | 117 +++---- .../oauth-client/src/__tests__/types.test.ts | 27 -- packages/oauth-client/src/client.ts | 59 +++- packages/oauth-client/src/dpop-proof.ts | 75 ---- packages/oauth-client/src/import-jwk.ts | 27 ++ packages/oauth-client/src/index.ts | 4 +- packages/oauth-client/src/session.ts | 99 ++++-- packages/oauth-client/src/types.ts | 9 - 20 files changed, 639 insertions(+), 995 deletions(-) delete mode 100644 packages/oauth-client-browser/src/__tests__/resolve.test.ts delete mode 100644 packages/oauth-client-browser/src/__tests__/web-crypto-adapter.test.ts delete mode 100644 packages/oauth-client-browser/src/resolve.ts delete mode 100644 packages/oauth-client-browser/src/web-crypto-adapter.ts delete mode 100644 packages/oauth-client/src/__tests__/dpop-proof.test.ts delete mode 100644 packages/oauth-client/src/dpop-proof.ts create mode 100644 packages/oauth-client/src/import-jwk.ts diff --git a/bun.lock b/bun.lock index b7245bd..cb20426 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,7 @@ "name": "@happyview/lex-agent", "version": "0.0.0-development", "dependencies": { - "@happyview/oauth-client": "workspace:*", + "@happyview/oauth-client": "^1.0.0-dev.2", }, "devDependencies": { "@atproto/lex": "^0.0.25", @@ -49,6 +49,10 @@ "packages/oauth-client": { "name": "@happyview/oauth-client", "version": "0.0.0-development", + "dependencies": { + "@atproto/jwk": "^0.6.0", + "@atproto/jwk-webcrypto": "^0.2.0", + }, "devDependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.0.0", @@ -65,6 +69,9 @@ "name": "@happyview/oauth-client-browser", "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": { @@ -135,6 +142,8 @@ "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], + "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], @@ -149,6 +158,12 @@ "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], + "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], + + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], + + "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], + "@atproto/lex": ["@atproto/lex@0.0.25", "", { "dependencies": { "@atproto/lex-builder": "^0.0.22", "@atproto/lex-client": "^0.0.20", "@atproto/lex-data": "^0.0.15", "@atproto/lex-installer": "^0.0.25", "@atproto/lex-json": "^0.0.16", "@atproto/lex-schema": "^0.0.19", "tslib": "^2.8.1", "yargs": "^17.0.0" }, "bin": { "ts-lex": "bin/lex", "lex": "bin/lex" } }, "sha512-kZk/Fjwpt7rz+XeS9D1jCdG14q8+FXdIOdlFWzXDbTzYxKE6Y3DBZablNgt1kI5ePNe3HYBNsdLSpdXusr8qzA=="], "@atproto/lex-builder": ["@atproto/lex-builder@0.0.22", "", { "dependencies": { "@atproto/lex-document": "^0.0.20", "@atproto/lex-schema": "^0.0.19", "prettier": "^3.2.5", "ts-morph": "^27.0.0", "tslib": "^2.8.1" } }, "sha512-XwnOloOS5VMkHmx3ZXHzNOh5NKx8F4aUKw/oRxKe028YWOCLiKu20fWQ11f0o8GHczKou5i6/8Qd89JRSCpibA=="], @@ -1893,6 +1908,8 @@ "joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="], + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -3053,6 +3070,8 @@ "@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/oauth-client-browser/package.json b/packages/oauth-client-browser/package.json index bd23dfc..58a13b5 100644 --- a/packages/oauth-client-browser/package.json +++ b/packages/oauth-client-browser/package.json @@ -31,7 +31,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@happyview/oauth-client": "workspace:*" + "@happyview/oauth-client": "workspace:*", + "@atproto-labs/handle-resolver": "^0.3.6", + "@atproto-labs/did-resolver": "^0.2.6", + "@atproto/did": "^0.3.0" }, "devDependencies": { "@happy-dom/global-registrator": "^16.0.0", 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 2368b28..56e5ab9 100644 --- a/packages/oauth-client-browser/src/__tests__/browser-client.test.ts +++ b/packages/oauth-client-browser/src/__tests__/browser-client.test.ts @@ -1,27 +1,23 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"; import { InvalidStateError, TokenExchangeError, - type CryptoAdapter, type StorageAdapter, } from "@happyview/oauth-client"; import { HappyViewBrowserClient } from "../browser-client"; import { LocalStorageAdapter } from "../local-storage-adapter"; -const mockCrypto: CryptoAdapter = { - generatePkceVerifier: async () => "mock-pkce-verifier", - computePkceChallenge: async (v: string) => `challenge-of-${v}`, - signEs256: async () => new Uint8Array(64), - sha256: async (data: Uint8Array) => { - const hash = await crypto.subtle.digest("SHA-256", data); - return new Uint8Array(hash); - }, - getRandomValues: (length: number) => { - const bytes = new Uint8Array(length); - crypto.getRandomValues(bytes); - return bytes; - }, -}; +// Generate a real ES256 JWK once for all tests that need importJwk to succeed +let testJwk: JsonWebKey; +beforeAll(async () => { + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + testJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); + delete testJwk.key_ops; +}); afterEach(() => { localStorage.clear(); @@ -31,7 +27,6 @@ function createClient(fetchFn?: typeof globalThis.fetch) { return new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", clientKey: "hvc_test", - crypto: mockCrypto, storage: new LocalStorageAdapter(), fetch: fetchFn, }); @@ -39,23 +34,33 @@ function createClient(fetchFn?: typeof globalThis.fetch) { function mockFetchForFullFlow() { return mock(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); + const url = input instanceof Request ? input.url : String(input); if (url.includes("dns.google")) { return new Response( JSON.stringify({ - Answer: [{ name: "_atproto.user.bsky.social.", type: 16, data: '"did=did:plc:testuser"' }], + Status: 0, + Answer: [{ name: "_atproto.user.bsky.social.", type: 16, TTL: 300, data: '"did=did:plc:abcdefghijklmnopqrstuvwx"' }], }), - { status: 200 }, + { status: 200, headers: { "content-type": "application/dns-json" } }, ); } - if (url.includes("plc.directory/did:plc:testuser")) { + if (url.includes("plc.directory")) { return new Response( JSON.stringify({ - id: "did:plc:testuser", + id: "did:plc:abcdefghijklmnopqrstuvwx", service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }], }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + if (url.includes(".well-known/oauth-protected-resource")) { + return new Response( + JSON.stringify({ + authorization_servers: ["https://pds.example.com"], + }), { status: 200 }, ); } @@ -76,7 +81,7 @@ function mockFetchForFullFlow() { return new Response( JSON.stringify({ provision_id: "hvp_test123", - dpop_key: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + dpop_key: testJwk, }), { status: 201 }, ); @@ -91,7 +96,7 @@ function mockFetchForFullFlow() { if (url.includes("/oauth/sessions") && init?.method === "POST") { return new Response( - JSON.stringify({ session_id: "sess_test", did: "did:plc:testuser" }), + JSON.stringify({ session_id: "sess_test", did: "did:plc:abcdefghijklmnopqrstuvwx" }), { status: 201 }, ); } @@ -103,7 +108,7 @@ function mockFetchForFullFlow() { refresh_token: "rt_test_token", token_type: "DPoP", scope: "atproto", - sub: "did:plc:testuser", + sub: "did:plc:abcdefghijklmnopqrstuvwx", iss: "https://pds.example.com", }), { status: 200 }, @@ -115,7 +120,7 @@ function mockFetchForFullFlow() { } describe("HappyViewBrowserClient", () => { - test("constructor sets up WebCryptoAdapter and LocalStorageAdapter by default", () => { + test("constructor sets up LocalStorageAdapter by default", () => { const client = new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", clientKey: "hvc_test", @@ -123,7 +128,7 @@ describe("HappyViewBrowserClient", () => { expect(client).toBeDefined(); }); - test("constructor accepts custom crypto and storage adapters", () => { + test("constructor accepts custom storage adapter", () => { const customStorage: StorageAdapter = { get: async () => null, set: async () => {}, @@ -132,7 +137,6 @@ describe("HappyViewBrowserClient", () => { const client = new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", clientKey: "hvc_test", - crypto: mockCrypto, storage: customStorage, }); expect(client).toBeDefined(); @@ -145,7 +149,7 @@ describe("HappyViewBrowserClient", () => { const authInfo = await client.prepareLogin("user.bsky.social"); expect(authInfo.authorizationUrl).toContain("pds.example.com"); - expect(authInfo.did).toBe("did:plc:testuser"); + expect(authInfo.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); const stateKey = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i), @@ -158,9 +162,9 @@ describe("HappyViewBrowserClient", () => { const client = createClient(fetchFn); const pendingState = { - did: "did:plc:testuser", + did: "did:plc:abcdefghijklmnopqrstuvwx", provisionId: "hvp_test123", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + rawJwk: testJwk, provisionPkceVerifier: "provision-verifier", authPkceVerifier: "auth-verifier", pdsUrl: "https://pds.example.com", @@ -174,7 +178,7 @@ describe("HappyViewBrowserClient", () => { ); const session = await client.callback("?code=auth-code-123&state=state123"); - expect(session.did).toBe("did:plc:testuser"); + expect(session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); // Verify token exchange included DPoP proof header const tokenCall = fetchFn.mock.calls.find( @@ -192,9 +196,9 @@ describe("HappyViewBrowserClient", () => { const client = createClient(fetchFn); const pendingState = { - did: "did:plc:testuser", + did: "did:plc:abcdefghijklmnopqrstuvwx", provisionId: "hvp_test123", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + rawJwk: testJwk, provisionPkceVerifier: "provision-verifier", authPkceVerifier: "auth-verifier", pdsUrl: "https://pds.example.com", @@ -225,9 +229,9 @@ describe("HappyViewBrowserClient", () => { const client = createClient(fetchFn); const pendingState = { - did: "did:plc:testuser", + did: "did:plc:abcdefghijklmnopqrstuvwx", provisionId: "hvp_test123", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + rawJwk: testJwk, provisionPkceVerifier: "provision-verifier", authPkceVerifier: "auth-verifier", pdsUrl: "https://pds.example.com", @@ -255,9 +259,9 @@ describe("HappyViewBrowserClient", () => { const client = createClient(fetchFn); const pendingState = { - did: "did:plc:testuser", + did: "did:plc:abcdefghijklmnopqrstuvwx", provisionId: "hvp_test123", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + rawJwk: testJwk, provisionPkceVerifier: "provision-verifier", authPkceVerifier: "auth-verifier", pdsUrl: "https://pds.example.com", @@ -287,9 +291,9 @@ describe("HappyViewBrowserClient", () => { const client = createClient(fetchFn); const pendingState = { - did: "did:plc:testuser", + did: "did:plc:abcdefghijklmnopqrstuvwx", provisionId: "hvp_test123", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + rawJwk: testJwk, provisionPkceVerifier: "provision-verifier", authPkceVerifier: "auth-verifier", pdsUrl: "https://pds.example.com", @@ -348,9 +352,9 @@ describe("HappyViewBrowserClient", () => { const client = createClient(fetchFn); const pendingState = { - did: "did:plc:testuser", + did: "did:plc:abcdefghijklmnopqrstuvwx", provisionId: "hvp_test123", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + rawJwk: testJwk, provisionPkceVerifier: "provision-verifier", authPkceVerifier: "auth-verifier", pdsUrl: "https://pds.example.com", @@ -386,13 +390,13 @@ describe("HappyViewBrowserClient", () => { // Simulate a stored session localStorage.setItem( "@happyview/oauth(happyview:last-active-did)", - "did:plc:testuser", + "did:plc:abcdefghijklmnopqrstuvwx", ); localStorage.setItem( - "@happyview/oauth(happyview:session:did:plc:testuser)", + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", JSON.stringify({ - did: "did:plc:testuser", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, accessToken: "at_stored", clientKey: "hvc_test", instanceUrl: "https://happyview.example.com", @@ -401,7 +405,7 @@ describe("HappyViewBrowserClient", () => { const session = await client.restore(); expect(session).not.toBeNull(); - expect(session!.did).toBe("did:plc:testuser"); + expect(session!.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); }); test("logout deletes session from server and storage", async () => { @@ -409,10 +413,10 @@ describe("HappyViewBrowserClient", () => { const client = createClient(fetchFn); localStorage.setItem( - "@happyview/oauth(happyview:session:did:plc:testuser)", + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", JSON.stringify({ - did: "did:plc:testuser", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, accessToken: "at_stored", clientKey: "hvc_test", instanceUrl: "https://happyview.example.com", @@ -420,7 +424,7 @@ describe("HappyViewBrowserClient", () => { ); localStorage.setItem( "@happyview/oauth(happyview:last-active-did)", - "did:plc:testuser", + "did:plc:abcdefghijklmnopqrstuvwx", ); // Mock the DELETE response @@ -429,10 +433,10 @@ describe("HappyViewBrowserClient", () => { }); const logoutClient = createClient(deleteFn); - await logoutClient.logout("did:plc:testuser"); + await logoutClient.logout("did:plc:abcdefghijklmnopqrstuvwx"); expect( - localStorage.getItem("@happyview/oauth(happyview:session:did:plc:testuser)"), + 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/__tests__/resolve.test.ts b/packages/oauth-client-browser/src/__tests__/resolve.test.ts deleted file mode 100644 index 4fe7942..0000000 --- a/packages/oauth-client-browser/src/__tests__/resolve.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import { ResolutionError } from "@happyview/oauth-client"; -import { - resolveAuthServerMetadata, - resolveDidDocument, - resolveHandleToDid, - resolvePdsUrl, -} from "../resolve"; - -function mockFetch(responses: Record) { - return mock(async (input: RequestInfo | URL) => { - const url = input instanceof URL ? input.toString() : String(input); - for (const [pattern, resp] of Object.entries(responses)) { - if (url.includes(pattern)) { - return new Response(JSON.stringify(resp.body), { status: resp.status }); - } - } - return new Response("not found", { status: 404 }); - }); -} - -describe("resolveHandleToDid", () => { - test("resolves via DNS-over-HTTPS", async () => { - const fetchFn = mockFetch({ - "dns-query": { - status: 200, - body: { - Answer: [ - { name: "_atproto.user.bsky.social.", type: 16, data: '"did=did:plc:abc123"' }, - ], - }, - }, - }); - const did = await resolveHandleToDid("user.bsky.social", fetchFn); - expect(did).toBe("did:plc:abc123"); - }); - - test("falls back to HTTP .well-known", async () => { - const fn = mock(async (input: RequestInfo | URL) => { - const url = String(input); - if (url.includes("dns-query")) { - return new Response(JSON.stringify({ Answer: [] }), { status: 200 }); - } - if (url.includes(".well-known/atproto-did")) { - return new Response("did:plc:fallback", { status: 200 }); - } - return new Response("not found", { status: 404 }); - }); - const did = await resolveHandleToDid("user.example.com", fn); - expect(did).toBe("did:plc:fallback"); - }); -}); - -describe("resolveDidDocument", () => { - test("resolves did:plc via plc.directory", async () => { - const fetchFn = mockFetch({ - "plc.directory/did:plc:abc": { - status: 200, - body: { - id: "did:plc:abc", - service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }], - }, - }, - }); - const doc = await resolveDidDocument("did:plc:abc", fetchFn); - expect(doc.id).toBe("did:plc:abc"); - }); - - test("resolves did:web via .well-known", async () => { - const fetchFn = mockFetch({ - "example.com/.well-known/did.json": { - status: 200, - body: { id: "did:web:example.com", service: [] }, - }, - }); - const doc = await resolveDidDocument("did:web:example.com", fetchFn); - expect(doc.id).toBe("did:web:example.com"); - }); - - test("resolves multi-segment did:web with path", async () => { - const fetchFn = mockFetch({ - "example.com/path/to/resource/.well-known/did.json": { - status: 200, - body: { id: "did:web:example.com:path:to:resource", service: [] }, - }, - }); - const doc = await resolveDidDocument("did:web:example.com:path:to:resource", fetchFn); - expect(doc.id).toBe("did:web:example.com:path:to:resource"); - }); -}); - -describe("resolvePdsUrl", () => { - test("extracts PDS URL from DID document", () => { - const doc = { - id: "did:plc:abc", - service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }], - }; - expect(resolvePdsUrl(doc)).toBe("https://pds.example.com"); - }); - - test("throws ResolutionError when no PDS service found", () => { - const doc = { id: "did:plc:abc", service: [] }; - try { - resolvePdsUrl(doc); - expect(true).toBe(false); - } catch (err) { - expect(err).toBeInstanceOf(ResolutionError); - } - }); -}); - -describe("resolveAuthServerMetadata", () => { - test("fetches .well-known/oauth-authorization-server from PDS", async () => { - const fetchFn = mockFetch({ - "pds.example.com/.well-known/oauth-authorization-server": { - status: 200, - body: { - 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", - }, - }, - }); - const meta = await resolveAuthServerMetadata("https://pds.example.com", fetchFn); - expect(meta.authorization_endpoint).toBe("https://pds.example.com/oauth/authorize"); - expect(meta.token_endpoint).toBe("https://pds.example.com/oauth/token"); - }); -}); diff --git a/packages/oauth-client-browser/src/__tests__/web-crypto-adapter.test.ts b/packages/oauth-client-browser/src/__tests__/web-crypto-adapter.test.ts deleted file mode 100644 index 2fd977b..0000000 --- a/packages/oauth-client-browser/src/__tests__/web-crypto-adapter.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { WebCryptoAdapter } from "../web-crypto-adapter"; - -const adapter = new WebCryptoAdapter(); - -describe("WebCryptoAdapter", () => { - describe("generatePkceVerifier", () => { - test("returns a string of 43-128 characters", async () => { - const verifier = await adapter.generatePkceVerifier(); - expect(verifier.length).toBeGreaterThanOrEqual(43); - expect(verifier.length).toBeLessThanOrEqual(128); - }); - - test("contains only valid characters [A-Za-z0-9-._~]", async () => { - const verifier = await adapter.generatePkceVerifier(); - expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/); - }); - - test("generates unique verifiers", async () => { - const v1 = await adapter.generatePkceVerifier(); - const v2 = await adapter.generatePkceVerifier(); - expect(v1).not.toBe(v2); - }); - }); - - describe("computePkceChallenge", () => { - test("returns base64url-encoded SHA-256 hash", async () => { - const challenge = await adapter.computePkceChallenge("test-verifier"); - expect(challenge).not.toContain("+"); - expect(challenge).not.toContain("/"); - expect(challenge).not.toContain("="); - expect(challenge.length).toBeGreaterThan(0); - }); - - test("produces consistent output for same input", async () => { - const c1 = await adapter.computePkceChallenge("same-verifier"); - const c2 = await adapter.computePkceChallenge("same-verifier"); - expect(c1).toBe(c2); - }); - - test("produces different output for different input", async () => { - const c1 = await adapter.computePkceChallenge("verifier-a"); - const c2 = await adapter.computePkceChallenge("verifier-b"); - expect(c1).not.toBe(c2); - }); - }); - - describe("signEs256", () => { - test("produces a 64-byte signature", async () => { - const keyPair = await crypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-256" }, - true, - ["sign", "verify"], - ); - const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey); - const payload = new TextEncoder().encode("test payload"); - - const sig = await adapter.signEs256(privateKey, payload); - expect(sig).toBeInstanceOf(Uint8Array); - expect(sig.length).toBe(64); - }); - - test("produces verifiable signatures", async () => { - const keyPair = await crypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-256" }, - true, - ["sign", "verify"], - ); - const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey); - const payload = new TextEncoder().encode("verify me"); - - const sig = await adapter.signEs256(privateKey, payload); - - const valid = await crypto.subtle.verify( - { name: "ECDSA", hash: "SHA-256" }, - keyPair.publicKey, - sig, - payload, - ); - expect(valid).toBe(true); - }); - }); - - describe("sha256", () => { - test("produces a 32-byte hash", async () => { - const data = new TextEncoder().encode("hello world"); - const hash = await adapter.sha256(data); - expect(hash).toBeInstanceOf(Uint8Array); - expect(hash.length).toBe(32); - }); - - test("produces consistent output for same input", async () => { - const data = new TextEncoder().encode("deterministic"); - const h1 = await adapter.sha256(data); - const h2 = await adapter.sha256(data); - expect(Array.from(h1)).toEqual(Array.from(h2)); - }); - - test("produces different output for different input", async () => { - const h1 = await adapter.sha256(new TextEncoder().encode("input-a")); - const h2 = await adapter.sha256(new TextEncoder().encode("input-b")); - expect(Array.from(h1)).not.toEqual(Array.from(h2)); - }); - }); - - describe("getRandomValues", () => { - test("returns Uint8Array of requested length", () => { - const bytes = adapter.getRandomValues(16); - expect(bytes).toBeInstanceOf(Uint8Array); - expect(bytes.length).toBe(16); - }); - - test("returns different values on each call", () => { - const a = adapter.getRandomValues(32); - const b = adapter.getRandomValues(32); - expect(Array.from(a)).not.toEqual(Array.from(b)); - }); - }); -}); diff --git a/packages/oauth-client-browser/src/browser-client.ts b/packages/oauth-client-browser/src/browser-client.ts index db699a9..9009c82 100644 --- a/packages/oauth-client-browser/src/browser-client.ts +++ b/packages/oauth-client-browser/src/browser-client.ts @@ -1,25 +1,21 @@ +import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; +import { DidResolverCommon } from "@atproto-labs/did-resolver"; +import type { DidDocument } from "@atproto/did"; import { - generateDpopProof, HappyViewOAuthClient, HappyViewSession, + importJwk, InvalidStateError, + ResolutionError, TokenExchangeError, - type CryptoAdapter, type StorageAdapter, } from "@happyview/oauth-client"; import { LocalStorageAdapter } from "./local-storage-adapter"; -import { - resolveAuthServerMetadata, - resolveDidDocument, - resolveHandleToDid, - resolvePdsUrl, -} from "./resolve"; -import { WebCryptoAdapter } from "./web-crypto-adapter"; export interface HappyViewBrowserClientOptions { instanceUrl: string; clientKey: string; - crypto?: CryptoAdapter; + scopes?: string; storage?: StorageAdapter; fetch?: typeof globalThis.fetch; } @@ -27,7 +23,7 @@ export interface HappyViewBrowserClientOptions { interface PendingAuthState { did: string; provisionId: string; - dpopKey: JsonWebKey; + rawJwk: JsonWebKey; provisionPkceVerifier: string; authPkceVerifier: string; pdsUrl: string; @@ -42,38 +38,57 @@ export interface PrepareLoginResult { state: string; } -export class HappyViewBrowserClient extends HappyViewOAuthClient { - private readonly _fetchFn: typeof globalThis.fetch; +interface AuthServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + pushed_authorization_request_endpoint?: string; + dpop_signing_alg_values_supported?: string[]; +} +export class HappyViewBrowserClient extends HappyViewOAuthClient { + private readonly handleResolver: AtprotoDohHandleResolver; + private readonly didResolver: DidResolverCommon; + private readonly scopes: string; constructor(options: HappyViewBrowserClientOptions) { - const fetchFn = options.fetch ?? globalThis.fetch; - const cryptoAdapter = options.crypto ?? new WebCryptoAdapter(); + const fetchFn = options.fetch ?? (((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch); const storageAdapter = options.storage ?? new LocalStorageAdapter(); super({ instanceUrl: options.instanceUrl, clientKey: options.clientKey, - crypto: cryptoAdapter, storage: storageAdapter, fetch: fetchFn, }); - this._fetchFn = fetchFn; + + this.scopes = options.scopes ?? "atproto"; + this.handleResolver = new AtprotoDohHandleResolver({ + dohEndpoint: "https://dns.google/resolve", + fetch: fetchFn, + }); + this.didResolver = new DidResolverCommon({ fetch: fetchFn }); } async prepareLogin(handle: string): Promise { - const did = await resolveHandleToDid(handle, this._fetchFn); - const doc = await resolveDidDocument(did, this._fetchFn); - const pdsUrl = resolvePdsUrl(doc); - const authMeta = await resolveAuthServerMetadata(pdsUrl, this._fetchFn); + // Resolve handle → DID → DID document → PDS URL → auth server metadata + const resolvedDid = await this.handleResolver.resolve(handle); + if (!resolvedDid) { + throw new ResolutionError(`Failed to resolve handle: ${handle}`); + } + const did = resolvedDid as string; + + const didDoc = await this.didResolver.resolve(resolvedDid); + const pdsUrl = extractPdsUrl(didDoc); + const authMeta = await this.fetchAuthServerMetadata(pdsUrl); - const { provisionId, dpopKey, pkceVerifier: provisionPkceVerifier } = + // Provision DPoP key from HappyView + const { provisionId, rawJwk, pkceVerifier: provisionPkceVerifier } = await this.provisionDpopKey(); // Separate PKCE for the PDS authorization server - const authPkceVerifier = await this.crypto.generatePkceVerifier(); - const authPkceChallenge = - await this.crypto.computePkceChallenge(authPkceVerifier); + const authPkceVerifier = generatePkceVerifier(); + const authPkceChallenge = await computePkceChallenge(authPkceVerifier); - const stateBytes = this.crypto.getRandomValues(16); + const stateBytes = crypto.getRandomValues(new Uint8Array(16)); const state = Array.from(stateBytes, (b) => b.toString(16).padStart(2, "0"), ).join(""); @@ -81,7 +96,7 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { const pendingState: PendingAuthState = { did, provisionId, - dpopKey, + rawJwk, provisionPkceVerifier: provisionPkceVerifier!, authPkceVerifier, pdsUrl, @@ -94,18 +109,50 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { JSON.stringify(pendingState), ); - const redirectUri = window.location.origin + "/oauth/callback"; - const params = new URLSearchParams({ + const { clientId, redirectUri } = this.resolveOAuthEndpoints(); + + const authParams = new URLSearchParams({ response_type: "code", - client_id: `${this.instanceUrl}/oauth-client-metadata.json`, + client_id: clientId, redirect_uri: redirectUri, state, - scope: "atproto", + scope: this.scopes, code_challenge: authPkceChallenge, code_challenge_method: "S256", + login_hint: handle, }); - const authorizationUrl = `${authMeta.authorization_endpoint}?${params}`; + // ATProto requires Pushed Authorization Requests (PAR) + const parEndpoint = authMeta.pushed_authorization_request_endpoint; + if (parEndpoint) { + const parResp = await this._fetch(parEndpoint, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: authParams, + }); + + if (!parResp.ok) { + const err = await parResp.text(); + throw new ResolutionError( + `PAR request failed: ${parResp.status} ${err}`, + ); + } + + const parData = (await parResp.json()) as { request_uri: string }; + const authorizationUrl = + `${authMeta.authorization_endpoint}?` + + new URLSearchParams({ + client_id: clientId, + request_uri: parData.request_uri, + }); + + return { authorizationUrl, did, state }; + } + + // Fallback: direct authorization URL (for servers that don't require PAR) + const authorizationUrl = `${authMeta.authorization_endpoint}?${authParams}`; return { authorizationUrl, did, state }; } @@ -121,43 +168,93 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { const state = params.get("state"); if (!code || !state) { - throw new InvalidStateError("Missing code or state in callback URL"); + 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}` + ); } const pendingJson = await this.storage.get(`pending-auth:${state}`); if (!pendingJson) { - throw new InvalidStateError("No pending auth state found for this callback"); + throw new InvalidStateError( + "No pending auth state found for this callback", + ); } const pending: PendingAuthState = JSON.parse(pendingJson); - // Generate DPoP proof for the token endpoint (no ath — no access token yet) - const dpopProof = await generateDpopProof(this.crypto, { - privateKey: pending.dpopKey, - method: "POST", - url: pending.tokenEndpoint, - }); + // 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; - const redirectUri = window.location.origin + "/oauth/callback"; - const tokenResp = await this._fetchFn(pending.tokenEndpoint, { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded", - dpop: dpopProof, - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - client_id: `${this.instanceUrl}/oauth-client-metadata.json`, - code_verifier: pending.authPkceVerifier, - }), - }); + const { clientId, redirectUri } = this.resolveOAuthEndpoints(); + + // 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; + + 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 } : {}), + }, + ); - if (!tokenResp.ok) { - const err = await tokenResp.text(); + 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; + } + // Not a nonce error — throw + throw new TokenExchangeError( + `Token exchange failed: ${tokenResp.status} ${errorBody}`, + tokenResp.status, + errorBody, + ); + } + } + + break; + } + + if (!tokenResp!.ok) { + const err = await tokenResp!.text(); throw new TokenExchangeError( - `Token exchange failed: ${tokenResp.status} ${err}`, - tokenResp.status, + `Token exchange failed: ${tokenResp!.status} ${err}`, + tokenResp!.status, err, ); } @@ -176,10 +273,10 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { did: pending.did, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, - scopes: tokens.scope ?? "atproto", + scopes: tokens.scope ?? this.scopes, pdsUrl: pending.pdsUrl, issuer: tokens.iss ?? pending.issuer, - dpopKey: pending.dpopKey, + dpopKey: pending.rawJwk, }); await this.storage.delete(`pending-auth:${state}`); @@ -190,4 +287,111 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { async logout(did: string): Promise { await this.deleteSession(did); } + + 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`, + }; + } + + private async fetchAuthServerMetadata( + pdsUrl: string, + ): Promise { + const base = pdsUrl.replace(/\/+$/, ""); + + const resourceResp = await this._fetch( + `${base}/.well-known/oauth-protected-resource`, + ); + if (!resourceResp.ok) { + throw new ResolutionError( + `Failed to fetch protected resource metadata from ${pdsUrl}: ${resourceResp.status}`, + ); + } + const resource = (await resourceResp.json()) as { + authorization_servers?: string[]; + }; + const authServer = resource.authorization_servers?.[0]; + if (!authServer) { + throw new ResolutionError( + `No authorization server found in protected resource metadata from ${pdsUrl}`, + ); + } + + const metaResp = await this._fetch( + `${authServer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`, + ); + if (!metaResp.ok) { + throw new ResolutionError( + `Failed to fetch auth server metadata from ${authServer}: ${metaResp.status}`, + ); + } + return metaResp.json(); + } +} + +function extractPdsUrl(doc: DidDocument): string { + const services = doc.service ?? []; + for (const service of services) { + if ( + service.id === "#atproto_pds" || + (typeof service.id === "string" && service.id.endsWith("#atproto_pds")) + ) { + if (typeof service.serviceEndpoint === "string") { + return service.serviceEndpoint; + } + throw new ResolutionError( + `#atproto_pds service endpoint is not a string URL in DID document for ${doc.id}`, + ); + } + } + throw new ResolutionError( + `No #atproto_pds service found in DID document for ${doc.id}`, + ); +} + +function randomHex(byteLength: number): string { + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function generatePkceVerifier(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function computePkceChallenge(verifier: string): Promise { + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(verifier), + ); + const bytes = new Uint8Array(hash); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); } diff --git a/packages/oauth-client-browser/src/index.ts b/packages/oauth-client-browser/src/index.ts index ce14887..a0fc391 100644 --- a/packages/oauth-client-browser/src/index.ts +++ b/packages/oauth-client-browser/src/index.ts @@ -7,9 +7,7 @@ export { MemoryStorage, ResolutionError, TokenExchangeError, - base64urlEncode, - generateDpopProof, - type CryptoAdapter, + importJwk, type DpopProvision, type HappyViewOAuthClientOptions, type RegisterSessionParams, @@ -23,11 +21,3 @@ export type { PrepareLoginResult, } from "./browser-client"; export { LocalStorageAdapter } from "./local-storage-adapter"; -export { - resolveAuthServerMetadata, - resolveDidDocument, - resolveHandleToDid, - resolvePdsUrl, -} from "./resolve"; -export type { AuthServerMetadata, DidDocument } from "./resolve"; -export { WebCryptoAdapter } from "./web-crypto-adapter"; diff --git a/packages/oauth-client-browser/src/resolve.ts b/packages/oauth-client-browser/src/resolve.ts deleted file mode 100644 index 5fadcd3..0000000 --- a/packages/oauth-client-browser/src/resolve.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { ResolutionError } from "@happyview/oauth-client"; - -export interface DidDocument { - id: string; - service: Array<{ - id: string; - type: string; - serviceEndpoint: string; - }>; -} - -export interface AuthServerMetadata { - issuer: string; - authorization_endpoint: string; - token_endpoint: string; - pushed_authorization_request_endpoint?: string; - dpop_signing_alg_values_supported?: string[]; -} - -export async function resolveHandleToDid( - handle: string, - fetchFn: typeof globalThis.fetch = globalThis.fetch, -): Promise { - try { - const dnsUrl = `https://dns.google/dns-query?name=_atproto.${handle}&type=TXT`; - const resp = await fetchFn(dnsUrl, { - headers: { accept: "application/dns-json" }, - }); - if (resp.ok) { - const data = await resp.json(); - const answers = (data as any).Answer ?? []; - for (const answer of answers) { - const txt = String(answer.data).replace(/^"|"$/g, ""); - if (txt.startsWith("did=")) { - return txt.slice(4); - } - } - } - } catch { - // DNS resolution failed, try HTTP fallback - } - - const wellKnownUrl = `https://${handle}/.well-known/atproto-did`; - const resp = await fetchFn(wellKnownUrl); - if (!resp.ok) { - throw new ResolutionError(`Failed to resolve handle ${handle}: ${resp.status}`); - } - const did = (await resp.text()).trim(); - if (!did.startsWith("did:")) { - throw new ResolutionError(`Invalid DID from handle resolution: ${did}`); - } - return did; -} - -export async function resolveDidDocument( - did: string, - fetchFn: typeof globalThis.fetch = globalThis.fetch, -): Promise { - let url: string; - if (did.startsWith("did:plc:")) { - url = `https://plc.directory/${did}`; - } else if (did.startsWith("did:web:")) { - const methodSpecific = did.slice("did:web:".length); - const parts = methodSpecific.split(":"); - const host = decodeURIComponent(parts[0]); - const path = parts.length > 1 ? "/" + parts.slice(1).map(decodeURIComponent).join("/") : ""; - url = `https://${host}${path}/.well-known/did.json`; - } else { - throw new ResolutionError(`Unsupported DID method: ${did}`); - } - - const resp = await fetchFn(url); - if (!resp.ok) { - throw new ResolutionError(`Failed to resolve DID ${did}: ${resp.status}`); - } - return resp.json(); -} - -export function resolvePdsUrl(doc: DidDocument): string { - for (const service of doc.service) { - if (service.id === "#atproto_pds" || service.id.endsWith("#atproto_pds")) { - return service.serviceEndpoint; - } - } - throw new ResolutionError(`No #atproto_pds service found in DID document for ${doc.id}`); -} - -export async function resolveAuthServerMetadata( - pdsUrl: string, - fetchFn: typeof globalThis.fetch = globalThis.fetch, -): Promise { - const url = `${pdsUrl.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`; - const resp = await fetchFn(url); - if (!resp.ok) { - throw new ResolutionError(`Failed to fetch auth server metadata from ${pdsUrl}: ${resp.status}`); - } - return resp.json(); -} diff --git a/packages/oauth-client-browser/src/web-crypto-adapter.ts b/packages/oauth-client-browser/src/web-crypto-adapter.ts deleted file mode 100644 index e11ed46..0000000 --- a/packages/oauth-client-browser/src/web-crypto-adapter.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { base64urlEncode, type CryptoAdapter } from "@happyview/oauth-client"; - -export class WebCryptoAdapter implements CryptoAdapter { - async generatePkceVerifier(): Promise { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return base64urlEncode(bytes); - } - - async computePkceChallenge(verifier: string): Promise { - const data = new TextEncoder().encode(verifier); - const hash = await crypto.subtle.digest("SHA-256", data); - return base64urlEncode(new Uint8Array(hash)); - } - - async signEs256( - privateKey: JsonWebKey, - payload: Uint8Array, - ): Promise { - const key = await crypto.subtle.importKey( - "jwk", - privateKey, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - key, - payload as unknown as ArrayBuffer, - ); - return new Uint8Array(sig); - } - - async sha256(data: Uint8Array): Promise { - const hash = await crypto.subtle.digest("SHA-256", data as unknown as ArrayBuffer); - return new Uint8Array(hash); - } - - getRandomValues(length: number): Uint8Array { - const bytes = new Uint8Array(length); - crypto.getRandomValues(bytes); - return bytes; - } -} diff --git a/packages/oauth-client/package.json b/packages/oauth-client/package.json index ae3c1d3..e05eb5d 100644 --- a/packages/oauth-client/package.json +++ b/packages/oauth-client/package.json @@ -30,6 +30,10 @@ "test": "bun test", "typecheck": "tsc --noEmit" }, + "dependencies": { + "@atproto/jwk": "^0.6.0", + "@atproto/jwk-webcrypto": "^0.2.0" + }, "devDependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.6", diff --git a/packages/oauth-client/src/__tests__/client.test.ts b/packages/oauth-client/src/__tests__/client.test.ts index 31bafb3..f80a592 100644 --- a/packages/oauth-client/src/__tests__/client.test.ts +++ b/packages/oauth-client/src/__tests__/client.test.ts @@ -2,22 +2,6 @@ import { describe, expect, mock, test } from "bun:test"; import { HappyViewOAuthClient } from "../client"; import { ApiError } from "../errors"; import { MemoryStorage } from "../storage"; -import type { CryptoAdapter } from "../types"; - -const testCrypto: CryptoAdapter = { - generatePkceVerifier: async () => "test-verifier-1234567890", - computePkceChallenge: async (v: string) => `challenge-of-${v}`, - signEs256: async () => new Uint8Array(64), - sha256: async (data: Uint8Array) => { - const hash = await crypto.subtle.digest("SHA-256", data); - return new Uint8Array(hash); - }, - getRandomValues: (length: number) => { - const bytes = new Uint8Array(length); - crypto.getRandomValues(bytes); - return bytes; - }, -}; function createMockFetch(responses: Array<{ status: number; body: unknown }>) { let callIndex = 0; @@ -26,7 +10,10 @@ function createMockFetch(responses: Array<{ status: number; body: unknown }>) { const fetchFn = mock(async (input: RequestInfo | URL, init?: RequestInit) => { const url = input instanceof URL ? input.toString() : String(input); calls.push({ url, init: init ?? {} }); - const resp = responses[callIndex] ?? { status: 500, body: { error: "no more mocked responses" } }; + const resp = responses[callIndex] ?? { + status: 500, + body: { error: "no more mocked responses" }, + }; callIndex++; return new Response(JSON.stringify(resp.body), { status: resp.status }); }); @@ -34,6 +21,18 @@ function createMockFetch(responses: Array<{ status: number; body: unknown }>) { return { fetchFn, calls }; } +async function generateTestJwk(): Promise { + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const jwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); + // Remove key_ops so importJwk can re-import with its own usage constraints + delete jwk.key_ops; + return jwk; +} + function createClient(overrides?: { fetchFn?: typeof globalThis.fetch; clientSecret?: string; @@ -43,7 +42,6 @@ function createClient(overrides?: { instanceUrl: "https://happyview.example.com", clientKey: "hvc_testkey", clientSecret: overrides?.clientSecret, - crypto: testCrypto, storage: overrides?.storage ?? new MemoryStorage(), fetch: overrides?.fetchFn, }); @@ -52,12 +50,13 @@ function createClient(overrides?: { describe("HappyViewOAuthClient", () => { describe("provisionDpopKey", () => { test("calls POST /oauth/dpop-keys with client credentials in headers", async () => { + const testJwk = await generateTestJwk(); const { fetchFn, calls } = createMockFetch([ { status: 201, body: { provision_id: "hvp_abc123", - dpop_key: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + dpop_key: testJwk, }, }, ]); @@ -65,21 +64,25 @@ describe("HappyViewOAuthClient", () => { const client = createClient({ fetchFn, clientSecret: "hvs_secret" }); const result = await client.provisionDpopKey(); - expect(calls[0].url).toBe("https://happyview.example.com/oauth/dpop-keys"); + expect(calls[0].url).toBe( + "https://happyview.example.com/oauth/dpop-keys", + ); const headers = new Headers(calls[0].init.headers); expect(headers.get("x-client-key")).toBe("hvc_testkey"); expect(headers.get("x-client-secret")).toBe("hvs_secret"); expect(result.provisionId).toBe("hvp_abc123"); - expect(result.dpopKey.kty).toBe("EC"); + expect(result.dpopKey).toBeDefined(); + expect(result.rawJwk).toBeDefined(); }); test("includes PKCE challenge for public clients and returns verifier", async () => { + const testJwk = await generateTestJwk(); const { fetchFn, calls } = createMockFetch([ { status: 201, body: { provision_id: "hvp_public", - dpop_key: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + dpop_key: testJwk, }, }, ]); @@ -88,17 +91,20 @@ describe("HappyViewOAuthClient", () => { const result = await client.provisionDpopKey(); const body = JSON.parse(calls[0].init.body as string); - expect(body.pkce_challenge).toBe("challenge-of-test-verifier-1234567890"); - expect(result.pkceVerifier).toBe("test-verifier-1234567890"); + expect(body.pkce_challenge).toBeDefined(); + expect(typeof body.pkce_challenge).toBe("string"); + expect(result.pkceVerifier).toBeDefined(); + expect(typeof result.pkceVerifier).toBe("string"); }); test("does not include PKCE for confidential clients", async () => { + const testJwk = await generateTestJwk(); const { fetchFn, calls } = createMockFetch([ { status: 201, body: { provision_id: "hvp_conf", - dpop_key: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + dpop_key: testJwk, }, }, ]); @@ -119,7 +125,7 @@ describe("HappyViewOAuthClient", () => { const client = createClient({ fetchFn }); try { await client.provisionDpopKey(); - expect(true).toBe(false); // should not reach + expect(true).toBe(false); } catch (err) { expect(err).toBeInstanceOf(ApiError); expect((err as ApiError).status).toBe(400); @@ -130,6 +136,7 @@ describe("HappyViewOAuthClient", () => { describe("registerSession", () => { test("calls POST /oauth/sessions and returns a HappyViewSession", async () => { + const testJwk = await generateTestJwk(); const { fetchFn, calls } = createMockFetch([ { status: 201, @@ -138,20 +145,27 @@ describe("HappyViewOAuthClient", () => { ]); const storage = new MemoryStorage(); - const client = createClient({ fetchFn, clientSecret: "hvs_sec", storage }); + const client = createClient({ + fetchFn, + clientSecret: "hvs_sec", + storage, + }); const session = await client.registerSession({ provisionId: "hvp_abc", did: "did:plc:testuser", accessToken: "at_token", scopes: "atproto", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + dpopKey: testJwk, }); - expect(calls[0].url).toBe("https://happyview.example.com/oauth/sessions"); + expect(calls[0].url).toBe( + "https://happyview.example.com/oauth/sessions", + ); expect(session.did).toBe("did:plc:testuser"); }); test("persists session and last active DID to storage", async () => { + const testJwk = await generateTestJwk(); const { fetchFn } = createMockFetch([ { status: 201, @@ -160,13 +174,17 @@ describe("HappyViewOAuthClient", () => { ]); const storage = new MemoryStorage(); - const client = createClient({ fetchFn, clientSecret: "hvs_sec", storage }); + const client = createClient({ + fetchFn, + clientSecret: "hvs_sec", + storage, + }); await client.registerSession({ provisionId: "hvp_abc", did: "did:plc:testuser", accessToken: "at_token", scopes: "atproto", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + dpopKey: testJwk, }); const stored = await storage.get("happyview:session:did:plc:testuser"); @@ -187,13 +205,22 @@ describe("HappyViewOAuthClient", () => { ]); const storage = new MemoryStorage(); - await storage.set("happyview:session:did:plc:testuser", JSON.stringify({ did: "did:plc:testuser" })); + await storage.set( + "happyview:session:did:plc:testuser", + JSON.stringify({ did: "did:plc:testuser" }), + ); await storage.set("happyview:last-active-did", "did:plc:testuser"); - const client = createClient({ fetchFn, clientSecret: "hvs_sec", storage }); + const client = createClient({ + fetchFn, + clientSecret: "hvs_sec", + storage, + }); await client.deleteSession("did:plc:testuser"); - expect(calls[0].url).toBe("https://happyview.example.com/oauth/sessions/did:plc:testuser"); + expect(calls[0].url).toBe( + "https://happyview.example.com/oauth/sessions/did:plc:testuser", + ); expect(calls[0].init.method).toBe("DELETE"); }); @@ -201,13 +228,22 @@ describe("HappyViewOAuthClient", () => { const { fetchFn } = createMockFetch([{ status: 204, body: null }]); const storage = new MemoryStorage(); - await storage.set("happyview:session:did:plc:testuser", JSON.stringify({ did: "did:plc:testuser" })); + await storage.set( + "happyview:session:did:plc:testuser", + JSON.stringify({ did: "did:plc:testuser" }), + ); await storage.set("happyview:last-active-did", "did:plc:testuser"); - const client = createClient({ fetchFn, clientSecret: "hvs_sec", storage }); + const client = createClient({ + fetchFn, + clientSecret: "hvs_sec", + storage, + }); await client.deleteSession("did:plc:testuser"); - expect(await storage.get("happyview:session:did:plc:testuser")).toBeNull(); + expect( + await storage.get("happyview:session:did:plc:testuser"), + ).toBeNull(); expect(await storage.get("happyview:last-active-did")).toBeNull(); }); @@ -215,14 +251,23 @@ describe("HappyViewOAuthClient", () => { const { fetchFn } = createMockFetch([{ status: 204, body: null }]); const storage = new MemoryStorage(); - await storage.set("happyview:session:did:plc:other", JSON.stringify({ did: "did:plc:other" })); + await storage.set( + "happyview:session:did:plc:other", + JSON.stringify({ did: "did:plc:other" }), + ); await storage.set("happyview:last-active-did", "did:plc:testuser"); - const client = createClient({ fetchFn, clientSecret: "hvs_sec", storage }); + const client = createClient({ + fetchFn, + clientSecret: "hvs_sec", + storage, + }); await client.deleteSession("did:plc:other"); expect(await storage.get("happyview:session:did:plc:other")).toBeNull(); - expect(await storage.get("happyview:last-active-did")).toBe("did:plc:testuser"); + expect(await storage.get("happyview:last-active-did")).toBe( + "did:plc:testuser", + ); }); }); @@ -234,12 +279,13 @@ describe("HappyViewOAuthClient", () => { }); test("restores session from storage", async () => { + const testJwk = await generateTestJwk(); const storage = new MemoryStorage(); await storage.set( "happyview:session:did:plc:testuser", JSON.stringify({ did: "did:plc:testuser", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + dpopKey: testJwk, accessToken: "at_stored", clientKey: "hvc_testkey", instanceUrl: "https://happyview.example.com", @@ -261,13 +307,14 @@ describe("HappyViewOAuthClient", () => { }); test("restores last active session", async () => { + const testJwk = await generateTestJwk(); const storage = new MemoryStorage(); await storage.set("happyview:last-active-did", "did:plc:testuser"); await storage.set( "happyview:session:did:plc:testuser", JSON.stringify({ did: "did:plc:testuser", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, + dpopKey: testJwk, accessToken: "at_stored", clientKey: "hvc_testkey", instanceUrl: "https://happyview.example.com", diff --git a/packages/oauth-client/src/__tests__/dpop-proof.test.ts b/packages/oauth-client/src/__tests__/dpop-proof.test.ts deleted file mode 100644 index a2e8469..0000000 --- a/packages/oauth-client/src/__tests__/dpop-proof.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { generateDpopProof } from "../dpop-proof"; -import type { CryptoAdapter } from "../types"; - -const testCrypto: CryptoAdapter = { - generatePkceVerifier: async () => "not-used-here", - computePkceChallenge: async () => "not-used-here", - signEs256: async ( - privateKey: JsonWebKey, - payload: Uint8Array, - ): Promise => { - const key = await crypto.subtle.importKey( - "jwk", - privateKey, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - key, - payload, - ); - return new Uint8Array(sig); - }, - sha256: async (data: Uint8Array): Promise => { - const hash = await crypto.subtle.digest("SHA-256", data); - return new Uint8Array(hash); - }, - getRandomValues: (length: number): Uint8Array => { - const bytes = new Uint8Array(length); - crypto.getRandomValues(bytes); - return bytes; - }, -}; - -async function generateTestKeyPair(): Promise<{ - privateKey: JsonWebKey; - publicKey: JsonWebKey; -}> { - const keyPair = await crypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-256" }, - true, - ["sign", "verify"], - ); - const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey); - const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey); - return { privateKey, publicKey }; -} - -function base64urlDecode(str: string): Uint8Array { - const padded = str + "=".repeat((4 - (str.length % 4)) % 4); - const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); - return Uint8Array.from(binary, (c) => c.charCodeAt(0)); -} - -describe("generateDpopProof", () => { - test("produces a valid 3-part JWT", async () => { - const { privateKey } = await generateTestKeyPair(); - const proof = await generateDpopProof(testCrypto, { - privateKey, - method: "POST", - url: "https://pds.example.com/xrpc/com.atproto.repo.createRecord", - accessToken: "test-access-token", - }); - - const parts = proof.split("."); - expect(parts).toHaveLength(3); - }); - - test("header has correct alg, typ, and jwk", async () => { - const { privateKey, publicKey } = await generateTestKeyPair(); - const proof = await generateDpopProof(testCrypto, { - privateKey, - method: "GET", - url: "https://pds.example.com/xrpc/test.method", - accessToken: "tok", - }); - - const header = JSON.parse( - new TextDecoder().decode(base64urlDecode(proof.split(".")[0])), - ); - expect(header.alg).toBe("ES256"); - expect(header.typ).toBe("dpop+jwt"); - expect(header.jwk.kty).toBe("EC"); - expect(header.jwk.crv).toBe("P-256"); - expect(header.jwk.x).toBe(publicKey.x); - expect(header.jwk.y).toBe(publicKey.y); - expect(header.jwk.d).toBeUndefined(); - }); - - test("payload has htm, htu, iat, ath, jti", async () => { - const { privateKey } = await generateTestKeyPair(); - const proof = await generateDpopProof(testCrypto, { - privateKey, - method: "POST", - url: "https://pds.example.com/xrpc/test.method", - accessToken: "my-access-token", - }); - - const payload = JSON.parse( - new TextDecoder().decode(base64urlDecode(proof.split(".")[1])), - ); - expect(payload.htm).toBe("POST"); - expect(payload.htu).toBe("https://pds.example.com/xrpc/test.method"); - expect(typeof payload.iat).toBe("number"); - expect(typeof payload.ath).toBe("string"); - expect(typeof payload.jti).toBe("string"); - expect(payload.ath.length).toBeGreaterThan(0); - expect(payload.jti.length).toBeGreaterThan(0); - }); - - test("ath is base64url(SHA-256(access_token))", async () => { - const { privateKey } = await generateTestKeyPair(); - const accessToken = "specific-test-token"; - const proof = await generateDpopProof(testCrypto, { - privateKey, - method: "GET", - url: "https://example.com/xrpc/test", - accessToken, - }); - - const payload = JSON.parse( - new TextDecoder().decode(base64urlDecode(proof.split(".")[1])), - ); - - const tokenBytes = new TextEncoder().encode(accessToken); - const hashBuf = await crypto.subtle.digest("SHA-256", tokenBytes); - const hashArr = new Uint8Array(hashBuf); - let binary = ""; - for (let i = 0; i < hashArr.length; i++) { - binary += String.fromCharCode(hashArr[i]); - } - const expected = btoa(binary) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - - expect(payload.ath).toBe(expected); - }); - - test("includes nonce when provided", async () => { - const { privateKey } = await generateTestKeyPair(); - const proof = await generateDpopProof(testCrypto, { - privateKey, - method: "GET", - url: "https://example.com/xrpc/test", - accessToken: "tok", - nonce: "server-nonce-123", - }); - - const payload = JSON.parse( - new TextDecoder().decode(base64urlDecode(proof.split(".")[1])), - ); - expect(payload.nonce).toBe("server-nonce-123"); - }); - - test("omits ath when accessToken is not provided", async () => { - const { privateKey } = await generateTestKeyPair(); - const proof = await generateDpopProof(testCrypto, { - privateKey, - method: "POST", - url: "https://pds.example.com/oauth/token", - }); - - const payload = JSON.parse( - new TextDecoder().decode(base64urlDecode(proof.split(".")[1])), - ); - expect(payload.ath).toBeUndefined(); - expect(payload.htm).toBe("POST"); - expect(payload.htu).toBe("https://pds.example.com/oauth/token"); - }); - - test("omits nonce when not provided", async () => { - const { privateKey } = await generateTestKeyPair(); - const proof = await generateDpopProof(testCrypto, { - privateKey, - method: "GET", - url: "https://example.com/xrpc/test", - accessToken: "tok", - }); - - const payload = JSON.parse( - new TextDecoder().decode(base64urlDecode(proof.split(".")[1])), - ); - expect(payload.nonce).toBeUndefined(); - }); - - test("signature is verifiable", async () => { - const { privateKey, publicKey } = await generateTestKeyPair(); - const proof = await generateDpopProof(testCrypto, { - privateKey, - method: "POST", - url: "https://pds.example.com/xrpc/test.method", - accessToken: "my-token", - }); - - const [headerB64, payloadB64, sigB64] = proof.split("."); - const message = new TextEncoder().encode(`${headerB64}.${payloadB64}`); - const signature = base64urlDecode(sigB64); - - const verifyKey = await crypto.subtle.importKey( - "jwk", - publicKey, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["verify"], - ); - const valid = await crypto.subtle.verify( - { name: "ECDSA", hash: "SHA-256" }, - verifyKey, - signature, - message, - ); - expect(valid).toBe(true); - }); -}); diff --git a/packages/oauth-client/src/__tests__/session.test.ts b/packages/oauth-client/src/__tests__/session.test.ts index 57c2cc7..ef73e26 100644 --- a/packages/oauth-client/src/__tests__/session.test.ts +++ b/packages/oauth-client/src/__tests__/session.test.ts @@ -1,53 +1,48 @@ import { describe, expect, mock, test } from "bun:test"; +import { WebcryptoKey } from "@atproto/jwk-webcrypto"; import { HappyViewSession } from "../session"; -import type { CryptoAdapter } from "../types"; - -const testCrypto: CryptoAdapter = { - generatePkceVerifier: async () => "not-used", - computePkceChallenge: async () => "not-used", - signEs256: async (_key: JsonWebKey, payload: Uint8Array) => { - return new Uint8Array(64); - }, - sha256: async (data: Uint8Array) => { - const hash = await crypto.subtle.digest("SHA-256", data); - return new Uint8Array(hash); - }, - getRandomValues: (length: number) => { - const bytes = new Uint8Array(length); - crypto.getRandomValues(bytes); - return bytes; - }, -}; + +async function generateTestKey(): Promise { + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + return WebcryptoKey.fromKeypair(keyPair); +} function createSession(overrides?: { instanceUrl?: string; + dpopKey?: WebcryptoKey; fetchMock?: typeof globalThis.fetch; }) { - return new HappyViewSession({ - did: "did:plc:testuser", - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, - accessToken: "test-access-token", - clientKey: "hvc_testkey", - instanceUrl: overrides?.instanceUrl ?? "https://happyview.example.com", - crypto: testCrypto, - fetch: overrides?.fetchMock, - }); + return async () => { + const dpopKey = overrides?.dpopKey ?? (await generateTestKey()); + return new HappyViewSession({ + did: "did:plc:testuser", + dpopKey, + accessToken: "test-access-token", + clientKey: "hvc_testkey", + instanceUrl: overrides?.instanceUrl ?? "https://happyview.example.com", + fetch: overrides?.fetchMock, + }); + }; } describe("HappyViewSession", () => { - test("exposes did property", () => { - const session = createSession(); + test("exposes did property", async () => { + const session = await createSession()(); expect(session.did).toBe("did:plc:testuser"); }); test("fetchHandler prepends instanceUrl to relative paths", async () => { let capturedUrl: string | undefined; - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = mock(async (input: RequestInfo | URL) => { capturedUrl = input instanceof URL ? input.toString() : String(input); return new Response(JSON.stringify({ ok: true }), { status: 200 }); }); - const session = createSession({ fetchMock }); + const session = await createSession({ fetchMock })(); await session.fetchHandler( "/xrpc/com.example.test.getStuff?param=value", {}, @@ -60,12 +55,12 @@ describe("HappyViewSession", () => { test("fetchHandler passes through absolute URLs without prepending", async () => { let capturedUrl: string | undefined; - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = mock(async (input: RequestInfo | URL) => { capturedUrl = input instanceof URL ? input.toString() : String(input); return new Response("{}", { status: 200 }); }); - const session = createSession({ fetchMock }); + const session = await createSession({ fetchMock })(); await session.fetchHandler( "https://other-service.example.com/xrpc/test.method", {}, @@ -78,26 +73,26 @@ describe("HappyViewSession", () => { test("fetchHandler adds Authorization DPoP header", async () => { let capturedInit: RequestInit | undefined; - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { capturedInit = init; return new Response("{}", { status: 200 }); }); - const session = createSession({ fetchMock }); + const session = await createSession({ fetchMock })(); await session.fetchHandler("/xrpc/test.method", {}); const headers = new Headers(capturedInit?.headers); expect(headers.get("authorization")).toBe("DPoP test-access-token"); }); - test("fetchHandler adds DPoP proof header", async () => { + test("fetchHandler adds DPoP proof header as valid JWT", async () => { let capturedInit: RequestInit | undefined; - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { capturedInit = init; return new Response("{}", { status: 200 }); }); - const session = createSession({ fetchMock }); + const session = await createSession({ fetchMock })(); await session.fetchHandler("/xrpc/test.method", {}); const headers = new Headers(capturedInit?.headers); @@ -108,12 +103,12 @@ describe("HappyViewSession", () => { test("fetchHandler adds X-Client-Key header", async () => { let capturedInit: RequestInit | undefined; - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { capturedInit = init; return new Response("{}", { status: 200 }); }); - const session = createSession({ fetchMock }); + const session = await createSession({ fetchMock })(); await session.fetchHandler("/xrpc/test.method", {}); const headers = new Headers(capturedInit?.headers); @@ -122,12 +117,12 @@ describe("HappyViewSession", () => { test("fetchHandler preserves existing headers from init", async () => { let capturedInit: RequestInit | undefined; - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { capturedInit = init; return new Response("{}", { status: 200 }); }); - const session = createSession({ fetchMock }); + const session = await createSession({ fetchMock })(); await session.fetchHandler("/xrpc/test.method", { headers: { "content-type": "application/json" }, }); @@ -138,17 +133,13 @@ describe("HappyViewSession", () => { }); test("fetchHandler stores DPoP-Nonce from response", async () => { - let callCount = 0; - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { - callCount++; + const fetchMock = mock(async () => { const headers = new Headers(); - if (callCount === 1) { - headers.set("dpop-nonce", "server-nonce-abc"); - } + headers.set("dpop-nonce", "server-nonce-abc"); return new Response("{}", { status: 200, headers }); }); - const session = createSession({ fetchMock }); + const session = await createSession({ fetchMock })(); await session.fetchHandler("/xrpc/test.method", {}); expect((session as any).dpopNonce).toBe("server-nonce-abc"); }); @@ -156,7 +147,7 @@ describe("HappyViewSession", () => { test("fetchHandler includes stored nonce in subsequent DPoP proofs", async () => { const capturedInits: RequestInit[] = []; let callCount = 0; - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { capturedInits.push(init ?? {}); callCount++; const headers = new Headers(); @@ -166,29 +157,23 @@ describe("HappyViewSession", () => { return new Response("{}", { status: 200, headers }); }); - const session = createSession({ fetchMock }); + const session = await createSession({ fetchMock })(); - // First request — no nonce yet await session.fetchHandler("/xrpc/test.method", {}); - - // Second request — should include nonce from first response await session.fetchHandler("/xrpc/test.method2", {}); - const firstDpop = new Headers(capturedInits[0].headers).get("dpop")!; - const secondDpop = new Headers(capturedInits[1].headers).get("dpop")!; - - function base64urlDecode(str: string): Uint8Array { - const padded = str + "=".repeat((4 - (str.length % 4)) % 4); + function decodeJwtPayload(jwt: string): Record { + const payloadB64 = jwt.split(".")[1]; + const padded = payloadB64 + "=".repeat((4 - (payloadB64.length % 4)) % 4); const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); - return Uint8Array.from(binary, (c) => c.charCodeAt(0)); + return JSON.parse(binary); } - const firstPayload = JSON.parse( - new TextDecoder().decode(base64urlDecode(firstDpop.split(".")[1])), - ); - const secondPayload = JSON.parse( - new TextDecoder().decode(base64urlDecode(secondDpop.split(".")[1])), - ); + const firstDpop = new Headers(capturedInits[0].headers).get("dpop")!; + const secondDpop = new Headers(capturedInits[1].headers).get("dpop")!; + + const firstPayload = decodeJwtPayload(firstDpop); + const secondPayload = decodeJwtPayload(secondDpop); expect(firstPayload.nonce).toBeUndefined(); expect(secondPayload.nonce).toBe("server-nonce-xyz"); diff --git a/packages/oauth-client/src/__tests__/types.test.ts b/packages/oauth-client/src/__tests__/types.test.ts index b5c1eba..2e85e6d 100644 --- a/packages/oauth-client/src/__tests__/types.test.ts +++ b/packages/oauth-client/src/__tests__/types.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test"; import type { - CryptoAdapter, DpopProvision, HappyViewOAuthClientOptions, RegisterSessionParams, @@ -13,13 +12,6 @@ describe("types", () => { instanceUrl: "https://example.com", clientKey: "hvc_test", clientSecret: "hvs_secret", - crypto: { - generatePkceVerifier: async () => "verifier", - computePkceChallenge: async () => "challenge", - signEs256: async () => new Uint8Array(64), - sha256: async () => new Uint8Array(32), - getRandomValues: (n) => new Uint8Array(n), - }, }; expect(opts.clientKey).toBe("hvc_test"); expect(opts.clientSecret).toBe("hvs_secret"); @@ -29,13 +21,6 @@ describe("types", () => { const opts: HappyViewOAuthClientOptions = { instanceUrl: "https://example.com", clientKey: "hvc_test", - crypto: { - generatePkceVerifier: async () => "verifier", - computePkceChallenge: async () => "challenge", - signEs256: async () => new Uint8Array(64), - sha256: async () => new Uint8Array(32), - getRandomValues: (n) => new Uint8Array(n), - }, }; expect(opts.clientSecret).toBeUndefined(); }); @@ -70,16 +55,4 @@ describe("types", () => { expect(storage.get).toBeFunction(); }); - test("CryptoAdapter interface shape", () => { - const adapter: CryptoAdapter = { - generatePkceVerifier: async () => "verifier", - computePkceChallenge: async () => "challenge", - signEs256: async () => new Uint8Array(64), - sha256: async () => new Uint8Array(32), - getRandomValues: (n) => new Uint8Array(n), - }; - expect(adapter.signEs256).toBeFunction(); - expect(adapter.sha256).toBeFunction(); - expect(adapter.getRandomValues).toBeFunction(); - }); }); diff --git a/packages/oauth-client/src/client.ts b/packages/oauth-client/src/client.ts index 8f5dd8b..df6e745 100644 --- a/packages/oauth-client/src/client.ts +++ b/packages/oauth-client/src/client.ts @@ -1,8 +1,9 @@ +import type { Key } from "@atproto/jwk"; import { ApiError } from "./errors"; +import { importJwk } from "./import-jwk"; import { HappyViewSession } from "./session"; import { MemoryStorage } from "./storage"; import type { - CryptoAdapter, HappyViewOAuthClientOptions, ProvisionKeyResponse, RegisterSessionParams, @@ -17,10 +18,9 @@ const LAST_ACTIVE_KEY = "happyview:last-active-did"; export class HappyViewOAuthClient { protected readonly instanceUrl: string; protected readonly clientKey: string; - protected readonly crypto: CryptoAdapter; protected readonly storage: StorageAdapter; private readonly clientSecret: string | undefined; - private readonly _fetch: typeof globalThis.fetch; + protected readonly _fetch: typeof globalThis.fetch; constructor( options: HappyViewOAuthClientOptions & { @@ -30,9 +30,8 @@ export class HappyViewOAuthClient { this.instanceUrl = options.instanceUrl.replace(/\/+$/, ""); this.clientKey = options.clientKey; this.clientSecret = options.clientSecret; - this.crypto = options.crypto; this.storage = options.storage ?? new MemoryStorage(); - this._fetch = options.fetch ?? globalThis.fetch; + this._fetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch; } get isConfidential(): boolean { @@ -41,7 +40,8 @@ export class HappyViewOAuthClient { async provisionDpopKey(): Promise<{ provisionId: string; - dpopKey: JsonWebKey; + dpopKey: Key; + rawJwk: JsonWebKey; pkceVerifier?: string; }> { const headers: Record = { @@ -56,8 +56,8 @@ export class HappyViewOAuthClient { let pkceVerifier: string | undefined; if (!this.isConfidential) { - pkceVerifier = await this.crypto.generatePkceVerifier(); - const challenge = await this.crypto.computePkceChallenge(pkceVerifier); + pkceVerifier = generatePkceVerifier(); + const challenge = await computePkceChallenge(pkceVerifier); body.pkce_challenge = challenge; } @@ -77,9 +77,11 @@ export class HappyViewOAuthClient { } const data: ProvisionKeyResponse = await resp.json(); + const dpopKey = await importJwk(data.dpop_key); return { provisionId: data.provision_id, - dpopKey: data.dpop_key, + dpopKey, + rawJwk: data.dpop_key, pkceVerifier, }; } @@ -125,6 +127,7 @@ export class HappyViewOAuthClient { } const data: RegisterSessionResponse = await resp.json(); + const dpopKey = await importJwk(params.dpopKey); const storedSession: StoredSession = { did: data.did, @@ -141,11 +144,11 @@ export class HappyViewOAuthClient { return new HappyViewSession({ did: data.did, - dpopKey: params.dpopKey, + dpopKey, accessToken: params.accessToken, clientKey: this.clientKey, instanceUrl: this.instanceUrl, - crypto: this.crypto, + fetch: this._fetch, }); } @@ -187,13 +190,14 @@ export class HappyViewOAuthClient { if (!stored) return null; const data: StoredSession = JSON.parse(stored); + const dpopKey = await importJwk(data.dpopKey); return new HappyViewSession({ did: data.did, - dpopKey: data.dpopKey, + dpopKey, accessToken: data.accessToken, clientKey: data.clientKey, instanceUrl: data.instanceUrl, - crypto: this.crypto, + fetch: this._fetch, }); } @@ -203,3 +207,32 @@ export class HappyViewOAuthClient { return this.restoreSession(did); } } + +// PKCE helpers — inline since they're just a few lines of Web Crypto +function generatePkceVerifier(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function computePkceChallenge(verifier: string): Promise { + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(verifier), + ); + const bytes = new Uint8Array(hash); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} diff --git a/packages/oauth-client/src/dpop-proof.ts b/packages/oauth-client/src/dpop-proof.ts deleted file mode 100644 index 7d2a5b9..0000000 --- a/packages/oauth-client/src/dpop-proof.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { CryptoAdapter } from "./types"; - -export function base64urlEncode(data: Uint8Array): string { - let binary = ""; - for (let i = 0; i < data.length; i++) { - binary += String.fromCharCode(data[i]); - } - return btoa(binary) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); -} - -export interface DpopProofParams { - privateKey: JsonWebKey; - method: string; - url: string; - accessToken?: string; - nonce?: string; -} - -export async function generateDpopProof( - crypto: CryptoAdapter, - params: DpopProofParams, -): Promise { - const { privateKey, method, url, accessToken, nonce } = params; - - const publicJwk = { - kty: privateKey.kty, - crv: privateKey.crv, - x: privateKey.x, - y: privateKey.y, - }; - - const header = { - alg: "ES256", - typ: "dpop+jwt", - jwk: publicJwk, - }; - - // Generate jti - const jtiBytes = crypto.getRandomValues(16); - const jti = Array.from(jtiBytes, (b) => b.toString(16).padStart(2, "0")).join(""); - - const payload: Record = { - htm: method, - htu: url, - iat: Math.floor(Date.now() / 1000), - jti, - }; - - // Compute ath: base64url(SHA-256(access_token)) — only when token is present - if (accessToken) { - const tokenBytes = new TextEncoder().encode(accessToken); - const hashBuf = await crypto.sha256(tokenBytes); - payload.ath = base64urlEncode(hashBuf); - } - - if (nonce !== undefined) { - payload.nonce = nonce; - } - - const headerB64 = base64urlEncode( - new TextEncoder().encode(JSON.stringify(header)), - ); - const payloadB64 = base64urlEncode( - new TextEncoder().encode(JSON.stringify(payload)), - ); - - const message = new TextEncoder().encode(`${headerB64}.${payloadB64}`); - const signature = await crypto.signEs256(privateKey, message); - const sigB64 = base64urlEncode(signature); - - return `${headerB64}.${payloadB64}.${sigB64}`; -} diff --git a/packages/oauth-client/src/import-jwk.ts b/packages/oauth-client/src/import-jwk.ts new file mode 100644 index 0000000..764b349 --- /dev/null +++ b/packages/oauth-client/src/import-jwk.ts @@ -0,0 +1,27 @@ +import { WebcryptoKey } from "@atproto/jwk-webcrypto"; + +/** + * Import a raw ES256 JWK (as returned by HappyView's /oauth/dpop-keys) + * into a WebcryptoKey that can sign JWTs. + */ +export async function importJwk(jwk: JsonWebKey): Promise { + const privateKey = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign"], + ); + + // Derive public key by stripping the private component + const { d: _, ...publicJwkFields } = jwk; + const publicKey = await crypto.subtle.importKey( + "jwk", + publicJwkFields, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["verify"], + ); + + return WebcryptoKey.fromKeypair({ privateKey, publicKey }); +} diff --git a/packages/oauth-client/src/index.ts b/packages/oauth-client/src/index.ts index 17fab10..bf772a0 100644 --- a/packages/oauth-client/src/index.ts +++ b/packages/oauth-client/src/index.ts @@ -1,6 +1,5 @@ export { HappyViewOAuthClient } from "./client"; -export { base64urlEncode, generateDpopProof } from "./dpop-proof"; -export type { DpopProofParams } from "./dpop-proof"; +export { importJwk } from "./import-jwk"; export { ApiError, AuthenticationError, @@ -13,7 +12,6 @@ export { HappyViewSession } from "./session"; export type { HappyViewSessionOptions } from "./session"; export { MemoryStorage } from "./storage"; export type { - CryptoAdapter, DpopProvision, HappyViewOAuthClientOptions, ProvisionKeyResponse, diff --git a/packages/oauth-client/src/session.ts b/packages/oauth-client/src/session.ts index 317fb93..564ed72 100644 --- a/packages/oauth-client/src/session.ts +++ b/packages/oauth-client/src/session.ts @@ -1,24 +1,38 @@ -import { generateDpopProof } from "./dpop-proof"; -import type { CryptoAdapter } from "./types"; +import type { Key } from "@atproto/jwk"; export interface HappyViewSessionOptions { did: string; - dpopKey: JsonWebKey; + dpopKey: Key; accessToken: string; clientKey: string; instanceUrl: string; - crypto: CryptoAdapter; fetch?: typeof globalThis.fetch; } +function randomHex(byteLength: number): string { + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function base64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + export class HappyViewSession { readonly did: string; - private readonly dpopKey: JsonWebKey; + private readonly dpopKey: Key; private readonly accessToken: string; private readonly clientKey: string; private readonly instanceUrl: string; - private readonly crypto: CryptoAdapter; private readonly _fetch: typeof globalThis.fetch; private dpopNonce: string | undefined; @@ -28,34 +42,69 @@ export class HappyViewSession { this.accessToken = options.accessToken; this.clientKey = options.clientKey; this.instanceUrl = options.instanceUrl.replace(/\/+$/, ""); - this.crypto = options.crypto; - this._fetch = options.fetch ?? globalThis.fetch; + this._fetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch; } async fetchHandler(url: string, init: RequestInit): Promise { - const fullUrl = /^https?:\/\//i.test(url) ? url : `${this.instanceUrl}${url}`; + const fullUrl = /^https?:\/\//i.test(url) + ? url + : `${this.instanceUrl}${url}`; const method = (init.method ?? "GET").toUpperCase(); + const htu = fullUrl.split("?")[0]; - const proof = await generateDpopProof(this.crypto, { - privateKey: this.dpopKey, - method, - url: fullUrl, - accessToken: this.accessToken, - nonce: this.dpopNonce, - }); + const sendWithProof = async (): Promise => { + const tokenHash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(this.accessToken), + ); + const ath = base64url(tokenHash); - const headers = new Headers(init.headers); - headers.set("authorization", `DPoP ${this.accessToken}`); - headers.set("dpop", proof); - headers.set("x-client-key", this.clientKey); + const proof = await this.dpopKey.createJwt( + { + alg: "ES256", + typ: "dpop+jwt", + jwk: this.dpopKey.publicJwk!, + }, + { + htm: method, + htu, + iat: Math.floor(Date.now() / 1000), + jti: randomHex(16), + ath, + nonce: this.dpopNonce, + }, + ); - const response = await this._fetch(fullUrl, { - ...init, - headers, - }); + const headers = new Headers(init.headers); + headers.set("authorization", `DPoP ${this.accessToken}`); + headers.set("dpop", proof); + headers.set("x-client-key", this.clientKey); + return this._fetch(fullUrl, { ...init, headers }); + }; + + let response: Response; + try { + response = await sendWithProof(); + } catch (e) { + const info = `_fetch type: ${typeof this._fetch}, toString: ${String(this._fetch).slice(0, 120)}, url: ${fullUrl}`; + throw new Error(`fetchHandler failed: ${(e as Error).message}\n\nDEBUG: ${info}`); + } + + // Retry once if the server requires a DPoP nonce we didn't have const nonce = response.headers.get("dpop-nonce"); - if (nonce) { + if (nonce && nonce !== this.dpopNonce && (response.status === 401 || response.status === 400)) { + this.dpopNonce = nonce; + try { + response = await sendWithProof(); + } catch (e) { + throw new Error(`fetchHandler retry failed: ${(e as Error).message}`); + } + const retryNonce = response.headers.get("dpop-nonce"); + if (retryNonce) { + this.dpopNonce = retryNonce; + } + } else if (nonce) { this.dpopNonce = nonce; } diff --git a/packages/oauth-client/src/types.ts b/packages/oauth-client/src/types.ts index f45fec1..c3a0dc9 100644 --- a/packages/oauth-client/src/types.ts +++ b/packages/oauth-client/src/types.ts @@ -1,11 +1,3 @@ -export interface CryptoAdapter { - generatePkceVerifier(): Promise; - computePkceChallenge(verifier: string): Promise; - signEs256(privateKey: JsonWebKey, payload: Uint8Array): Promise; - sha256(data: Uint8Array): Promise; - getRandomValues(length: number): Uint8Array; -} - export interface StorageAdapter { get(key: string): Promise; set(key: string, value: string): Promise; @@ -16,7 +8,6 @@ export interface HappyViewOAuthClientOptions { instanceUrl: string; clientKey: string; clientSecret?: string; - crypto: CryptoAdapter; storage?: StorageAdapter; } -- 2.51.2