A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(sdk): take advantage of prior art on crypto from bsky

Trezy 8d6da9e2 3cafc69d

+638 -994
+20 -1
bun.lock
··· 28 28 "name": "@happyview/lex-agent", 29 29 "version": "0.0.0-development", 30 30 "dependencies": { 31 - "@happyview/oauth-client": "workspace:*", 31 + "@happyview/oauth-client": "^1.0.0-dev.2", 32 32 }, 33 33 "devDependencies": { 34 34 "@atproto/lex": "^0.0.25", ··· 49 49 "packages/oauth-client": { 50 50 "name": "@happyview/oauth-client", 51 51 "version": "0.0.0-development", 52 + "dependencies": { 53 + "@atproto/jwk": "^0.6.0", 54 + "@atproto/jwk-webcrypto": "^0.2.0", 55 + }, 52 56 "devDependencies": { 53 57 "@semantic-release/commit-analyzer": "^13.0.1", 54 58 "@semantic-release/exec": "^7.0.0", ··· 65 69 "name": "@happyview/oauth-client-browser", 66 70 "version": "0.0.0-development", 67 71 "dependencies": { 72 + "@atproto-labs/did-resolver": "^0.2.6", 73 + "@atproto-labs/handle-resolver": "^0.3.6", 74 + "@atproto/did": "^0.3.0", 68 75 "@happyview/oauth-client": "workspace:*", 69 76 }, 70 77 "devDependencies": { ··· 135 142 136 143 "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 137 144 145 + "@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=="], 146 + 138 147 "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], 139 148 140 149 "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], ··· 148 157 "@atproto/crypto": ["@atproto/crypto@0.4.5", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "3.0.0" } }, "sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw=="], 149 158 150 159 "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], 160 + 161 + "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 162 + 163 + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], 164 + 165 + "@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=="], 151 166 152 167 "@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=="], 153 168 ··· 1893 1908 1894 1909 "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=="], 1895 1910 1911 + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 1912 + 1896 1913 "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], 1897 1914 1898 1915 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], ··· 3052 3069 "@docsearch/react/@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.19.2", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.19.2", "@algolia/autocomplete-shared": "1.19.2" } }, "sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw=="], 3053 3070 3054 3071 "@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=="], 3072 + 3073 + "@happyview/lex-agent/@happyview/oauth-client": ["@happyview/oauth-client@1.0.0", "", {}, "sha512-hvVeyVHJStZ/nrqolcWfy39AJuaws0axKx7b3UvXTSi2TmnTeCJOnelnHstt0L9S6P6b7xxP8ujzvcWZsOd0Ig=="], 3055 3074 3056 3075 "@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=="], 3057 3076
+4 -1
packages/oauth-client-browser/package.json
··· 31 31 "typecheck": "tsc --noEmit" 32 32 }, 33 33 "dependencies": { 34 - "@happyview/oauth-client": "workspace:*" 34 + "@happyview/oauth-client": "workspace:*", 35 + "@atproto-labs/handle-resolver": "^0.3.6", 36 + "@atproto-labs/did-resolver": "^0.2.6", 37 + "@atproto/did": "^0.3.0" 35 38 }, 36 39 "devDependencies": { 37 40 "@happy-dom/global-registrator": "^16.0.0",
+57 -53
packages/oauth-client-browser/src/__tests__/browser-client.test.ts
··· 1 - import { afterEach, describe, expect, mock, test } from "bun:test"; 1 + import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"; 2 2 import { 3 3 InvalidStateError, 4 4 TokenExchangeError, 5 - type CryptoAdapter, 6 5 type StorageAdapter, 7 6 } from "@happyview/oauth-client"; 8 7 import { HappyViewBrowserClient } from "../browser-client"; 9 8 import { LocalStorageAdapter } from "../local-storage-adapter"; 10 9 11 - const mockCrypto: CryptoAdapter = { 12 - generatePkceVerifier: async () => "mock-pkce-verifier", 13 - computePkceChallenge: async (v: string) => `challenge-of-${v}`, 14 - signEs256: async () => new Uint8Array(64), 15 - sha256: async (data: Uint8Array) => { 16 - const hash = await crypto.subtle.digest("SHA-256", data); 17 - return new Uint8Array(hash); 18 - }, 19 - getRandomValues: (length: number) => { 20 - const bytes = new Uint8Array(length); 21 - crypto.getRandomValues(bytes); 22 - return bytes; 23 - }, 24 - }; 10 + // Generate a real ES256 JWK once for all tests that need importJwk to succeed 11 + let testJwk: JsonWebKey; 12 + beforeAll(async () => { 13 + const keyPair = await crypto.subtle.generateKey( 14 + { name: "ECDSA", namedCurve: "P-256" }, 15 + true, 16 + ["sign", "verify"], 17 + ); 18 + testJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); 19 + delete testJwk.key_ops; 20 + }); 25 21 26 22 afterEach(() => { 27 23 localStorage.clear(); ··· 31 27 return new HappyViewBrowserClient({ 32 28 instanceUrl: "https://happyview.example.com", 33 29 clientKey: "hvc_test", 34 - crypto: mockCrypto, 35 30 storage: new LocalStorageAdapter(), 36 31 fetch: fetchFn, 37 32 }); ··· 39 34 40 35 function mockFetchForFullFlow() { 41 36 return mock(async (input: RequestInfo | URL, init?: RequestInit) => { 42 - const url = String(input); 37 + const url = input instanceof Request ? input.url : String(input); 43 38 44 39 if (url.includes("dns.google")) { 45 40 return new Response( 46 41 JSON.stringify({ 47 - Answer: [{ name: "_atproto.user.bsky.social.", type: 16, data: '"did=did:plc:testuser"' }], 42 + Status: 0, 43 + Answer: [{ name: "_atproto.user.bsky.social.", type: 16, TTL: 300, data: '"did=did:plc:abcdefghijklmnopqrstuvwx"' }], 48 44 }), 49 - { status: 200 }, 45 + { status: 200, headers: { "content-type": "application/dns-json" } }, 50 46 ); 51 47 } 52 48 53 - if (url.includes("plc.directory/did:plc:testuser")) { 49 + if (url.includes("plc.directory")) { 54 50 return new Response( 55 51 JSON.stringify({ 56 - id: "did:plc:testuser", 52 + id: "did:plc:abcdefghijklmnopqrstuvwx", 57 53 service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }], 58 54 }), 55 + { status: 200, headers: { "content-type": "application/json" } }, 56 + ); 57 + } 58 + 59 + if (url.includes(".well-known/oauth-protected-resource")) { 60 + return new Response( 61 + JSON.stringify({ 62 + authorization_servers: ["https://pds.example.com"], 63 + }), 59 64 { status: 200 }, 60 65 ); 61 66 } ··· 76 81 return new Response( 77 82 JSON.stringify({ 78 83 provision_id: "hvp_test123", 79 - dpop_key: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 84 + dpop_key: testJwk, 80 85 }), 81 86 { status: 201 }, 82 87 ); ··· 91 96 92 97 if (url.includes("/oauth/sessions") && init?.method === "POST") { 93 98 return new Response( 94 - JSON.stringify({ session_id: "sess_test", did: "did:plc:testuser" }), 99 + JSON.stringify({ session_id: "sess_test", did: "did:plc:abcdefghijklmnopqrstuvwx" }), 95 100 { status: 201 }, 96 101 ); 97 102 } ··· 103 108 refresh_token: "rt_test_token", 104 109 token_type: "DPoP", 105 110 scope: "atproto", 106 - sub: "did:plc:testuser", 111 + sub: "did:plc:abcdefghijklmnopqrstuvwx", 107 112 iss: "https://pds.example.com", 108 113 }), 109 114 { status: 200 }, ··· 115 120 } 116 121 117 122 describe("HappyViewBrowserClient", () => { 118 - test("constructor sets up WebCryptoAdapter and LocalStorageAdapter by default", () => { 123 + test("constructor sets up LocalStorageAdapter by default", () => { 119 124 const client = new HappyViewBrowserClient({ 120 125 instanceUrl: "https://happyview.example.com", 121 126 clientKey: "hvc_test", ··· 123 128 expect(client).toBeDefined(); 124 129 }); 125 130 126 - test("constructor accepts custom crypto and storage adapters", () => { 131 + test("constructor accepts custom storage adapter", () => { 127 132 const customStorage: StorageAdapter = { 128 133 get: async () => null, 129 134 set: async () => {}, ··· 132 137 const client = new HappyViewBrowserClient({ 133 138 instanceUrl: "https://happyview.example.com", 134 139 clientKey: "hvc_test", 135 - crypto: mockCrypto, 136 140 storage: customStorage, 137 141 }); 138 142 expect(client).toBeDefined(); ··· 145 149 const authInfo = await client.prepareLogin("user.bsky.social"); 146 150 147 151 expect(authInfo.authorizationUrl).toContain("pds.example.com"); 148 - expect(authInfo.did).toBe("did:plc:testuser"); 152 + expect(authInfo.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); 149 153 150 154 const stateKey = Array.from({ length: localStorage.length }, (_, i) => 151 155 localStorage.key(i), ··· 158 162 const client = createClient(fetchFn); 159 163 160 164 const pendingState = { 161 - did: "did:plc:testuser", 165 + did: "did:plc:abcdefghijklmnopqrstuvwx", 162 166 provisionId: "hvp_test123", 163 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 167 + rawJwk: testJwk, 164 168 provisionPkceVerifier: "provision-verifier", 165 169 authPkceVerifier: "auth-verifier", 166 170 pdsUrl: "https://pds.example.com", ··· 174 178 ); 175 179 176 180 const session = await client.callback("?code=auth-code-123&state=state123"); 177 - expect(session.did).toBe("did:plc:testuser"); 181 + expect(session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); 178 182 179 183 // Verify token exchange included DPoP proof header 180 184 const tokenCall = fetchFn.mock.calls.find( ··· 192 196 const client = createClient(fetchFn); 193 197 194 198 const pendingState = { 195 - did: "did:plc:testuser", 199 + did: "did:plc:abcdefghijklmnopqrstuvwx", 196 200 provisionId: "hvp_test123", 197 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 201 + rawJwk: testJwk, 198 202 provisionPkceVerifier: "provision-verifier", 199 203 authPkceVerifier: "auth-verifier", 200 204 pdsUrl: "https://pds.example.com", ··· 225 229 const client = createClient(fetchFn); 226 230 227 231 const pendingState = { 228 - did: "did:plc:testuser", 232 + did: "did:plc:abcdefghijklmnopqrstuvwx", 229 233 provisionId: "hvp_test123", 230 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 234 + rawJwk: testJwk, 231 235 provisionPkceVerifier: "provision-verifier", 232 236 authPkceVerifier: "auth-verifier", 233 237 pdsUrl: "https://pds.example.com", ··· 255 259 const client = createClient(fetchFn); 256 260 257 261 const pendingState = { 258 - did: "did:plc:testuser", 262 + did: "did:plc:abcdefghijklmnopqrstuvwx", 259 263 provisionId: "hvp_test123", 260 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 264 + rawJwk: testJwk, 261 265 provisionPkceVerifier: "provision-verifier", 262 266 authPkceVerifier: "auth-verifier", 263 267 pdsUrl: "https://pds.example.com", ··· 287 291 const client = createClient(fetchFn); 288 292 289 293 const pendingState = { 290 - did: "did:plc:testuser", 294 + did: "did:plc:abcdefghijklmnopqrstuvwx", 291 295 provisionId: "hvp_test123", 292 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 296 + rawJwk: testJwk, 293 297 provisionPkceVerifier: "provision-verifier", 294 298 authPkceVerifier: "auth-verifier", 295 299 pdsUrl: "https://pds.example.com", ··· 348 352 const client = createClient(fetchFn); 349 353 350 354 const pendingState = { 351 - did: "did:plc:testuser", 355 + did: "did:plc:abcdefghijklmnopqrstuvwx", 352 356 provisionId: "hvp_test123", 353 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 357 + rawJwk: testJwk, 354 358 provisionPkceVerifier: "provision-verifier", 355 359 authPkceVerifier: "auth-verifier", 356 360 pdsUrl: "https://pds.example.com", ··· 386 390 // Simulate a stored session 387 391 localStorage.setItem( 388 392 "@happyview/oauth(happyview:last-active-did)", 389 - "did:plc:testuser", 393 + "did:plc:abcdefghijklmnopqrstuvwx", 390 394 ); 391 395 localStorage.setItem( 392 - "@happyview/oauth(happyview:session:did:plc:testuser)", 396 + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", 393 397 JSON.stringify({ 394 - did: "did:plc:testuser", 395 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 398 + did: "did:plc:abcdefghijklmnopqrstuvwx", 399 + dpopKey: testJwk, 396 400 accessToken: "at_stored", 397 401 clientKey: "hvc_test", 398 402 instanceUrl: "https://happyview.example.com", ··· 401 405 402 406 const session = await client.restore(); 403 407 expect(session).not.toBeNull(); 404 - expect(session!.did).toBe("did:plc:testuser"); 408 + expect(session!.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); 405 409 }); 406 410 407 411 test("logout deletes session from server and storage", async () => { ··· 409 413 const client = createClient(fetchFn); 410 414 411 415 localStorage.setItem( 412 - "@happyview/oauth(happyview:session:did:plc:testuser)", 416 + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", 413 417 JSON.stringify({ 414 - did: "did:plc:testuser", 415 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 418 + did: "did:plc:abcdefghijklmnopqrstuvwx", 419 + dpopKey: testJwk, 416 420 accessToken: "at_stored", 417 421 clientKey: "hvc_test", 418 422 instanceUrl: "https://happyview.example.com", ··· 420 424 ); 421 425 localStorage.setItem( 422 426 "@happyview/oauth(happyview:last-active-did)", 423 - "did:plc:testuser", 427 + "did:plc:abcdefghijklmnopqrstuvwx", 424 428 ); 425 429 426 430 // Mock the DELETE response ··· 429 433 }); 430 434 const logoutClient = createClient(deleteFn); 431 435 432 - await logoutClient.logout("did:plc:testuser"); 436 + await logoutClient.logout("did:plc:abcdefghijklmnopqrstuvwx"); 433 437 434 438 expect( 435 - localStorage.getItem("@happyview/oauth(happyview:session:did:plc:testuser)"), 439 + localStorage.getItem("@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)"), 436 440 ).toBeNull(); 437 441 expect( 438 442 localStorage.getItem("@happyview/oauth(happyview:last-active-did)"),
-129
packages/oauth-client-browser/src/__tests__/resolve.test.ts
··· 1 - import { describe, expect, mock, test } from "bun:test"; 2 - import { ResolutionError } from "@happyview/oauth-client"; 3 - import { 4 - resolveAuthServerMetadata, 5 - resolveDidDocument, 6 - resolveHandleToDid, 7 - resolvePdsUrl, 8 - } from "../resolve"; 9 - 10 - function mockFetch(responses: Record<string, { status: number; body: unknown }>) { 11 - return mock(async (input: RequestInfo | URL) => { 12 - const url = input instanceof URL ? input.toString() : String(input); 13 - for (const [pattern, resp] of Object.entries(responses)) { 14 - if (url.includes(pattern)) { 15 - return new Response(JSON.stringify(resp.body), { status: resp.status }); 16 - } 17 - } 18 - return new Response("not found", { status: 404 }); 19 - }); 20 - } 21 - 22 - describe("resolveHandleToDid", () => { 23 - test("resolves via DNS-over-HTTPS", async () => { 24 - const fetchFn = mockFetch({ 25 - "dns-query": { 26 - status: 200, 27 - body: { 28 - Answer: [ 29 - { name: "_atproto.user.bsky.social.", type: 16, data: '"did=did:plc:abc123"' }, 30 - ], 31 - }, 32 - }, 33 - }); 34 - const did = await resolveHandleToDid("user.bsky.social", fetchFn); 35 - expect(did).toBe("did:plc:abc123"); 36 - }); 37 - 38 - test("falls back to HTTP .well-known", async () => { 39 - const fn = mock(async (input: RequestInfo | URL) => { 40 - const url = String(input); 41 - if (url.includes("dns-query")) { 42 - return new Response(JSON.stringify({ Answer: [] }), { status: 200 }); 43 - } 44 - if (url.includes(".well-known/atproto-did")) { 45 - return new Response("did:plc:fallback", { status: 200 }); 46 - } 47 - return new Response("not found", { status: 404 }); 48 - }); 49 - const did = await resolveHandleToDid("user.example.com", fn); 50 - expect(did).toBe("did:plc:fallback"); 51 - }); 52 - }); 53 - 54 - describe("resolveDidDocument", () => { 55 - test("resolves did:plc via plc.directory", async () => { 56 - const fetchFn = mockFetch({ 57 - "plc.directory/did:plc:abc": { 58 - status: 200, 59 - body: { 60 - id: "did:plc:abc", 61 - service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }], 62 - }, 63 - }, 64 - }); 65 - const doc = await resolveDidDocument("did:plc:abc", fetchFn); 66 - expect(doc.id).toBe("did:plc:abc"); 67 - }); 68 - 69 - test("resolves did:web via .well-known", async () => { 70 - const fetchFn = mockFetch({ 71 - "example.com/.well-known/did.json": { 72 - status: 200, 73 - body: { id: "did:web:example.com", service: [] }, 74 - }, 75 - }); 76 - const doc = await resolveDidDocument("did:web:example.com", fetchFn); 77 - expect(doc.id).toBe("did:web:example.com"); 78 - }); 79 - 80 - test("resolves multi-segment did:web with path", async () => { 81 - const fetchFn = mockFetch({ 82 - "example.com/path/to/resource/.well-known/did.json": { 83 - status: 200, 84 - body: { id: "did:web:example.com:path:to:resource", service: [] }, 85 - }, 86 - }); 87 - const doc = await resolveDidDocument("did:web:example.com:path:to:resource", fetchFn); 88 - expect(doc.id).toBe("did:web:example.com:path:to:resource"); 89 - }); 90 - }); 91 - 92 - describe("resolvePdsUrl", () => { 93 - test("extracts PDS URL from DID document", () => { 94 - const doc = { 95 - id: "did:plc:abc", 96 - service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example.com" }], 97 - }; 98 - expect(resolvePdsUrl(doc)).toBe("https://pds.example.com"); 99 - }); 100 - 101 - test("throws ResolutionError when no PDS service found", () => { 102 - const doc = { id: "did:plc:abc", service: [] }; 103 - try { 104 - resolvePdsUrl(doc); 105 - expect(true).toBe(false); 106 - } catch (err) { 107 - expect(err).toBeInstanceOf(ResolutionError); 108 - } 109 - }); 110 - }); 111 - 112 - describe("resolveAuthServerMetadata", () => { 113 - test("fetches .well-known/oauth-authorization-server from PDS", async () => { 114 - const fetchFn = mockFetch({ 115 - "pds.example.com/.well-known/oauth-authorization-server": { 116 - status: 200, 117 - body: { 118 - issuer: "https://pds.example.com", 119 - authorization_endpoint: "https://pds.example.com/oauth/authorize", 120 - token_endpoint: "https://pds.example.com/oauth/token", 121 - pushed_authorization_request_endpoint: "https://pds.example.com/oauth/par", 122 - }, 123 - }, 124 - }); 125 - const meta = await resolveAuthServerMetadata("https://pds.example.com", fetchFn); 126 - expect(meta.authorization_endpoint).toBe("https://pds.example.com/oauth/authorize"); 127 - expect(meta.token_endpoint).toBe("https://pds.example.com/oauth/token"); 128 - }); 129 - });
-119
packages/oauth-client-browser/src/__tests__/web-crypto-adapter.test.ts
··· 1 - import { describe, expect, test } from "bun:test"; 2 - import { WebCryptoAdapter } from "../web-crypto-adapter"; 3 - 4 - const adapter = new WebCryptoAdapter(); 5 - 6 - describe("WebCryptoAdapter", () => { 7 - describe("generatePkceVerifier", () => { 8 - test("returns a string of 43-128 characters", async () => { 9 - const verifier = await adapter.generatePkceVerifier(); 10 - expect(verifier.length).toBeGreaterThanOrEqual(43); 11 - expect(verifier.length).toBeLessThanOrEqual(128); 12 - }); 13 - 14 - test("contains only valid characters [A-Za-z0-9-._~]", async () => { 15 - const verifier = await adapter.generatePkceVerifier(); 16 - expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/); 17 - }); 18 - 19 - test("generates unique verifiers", async () => { 20 - const v1 = await adapter.generatePkceVerifier(); 21 - const v2 = await adapter.generatePkceVerifier(); 22 - expect(v1).not.toBe(v2); 23 - }); 24 - }); 25 - 26 - describe("computePkceChallenge", () => { 27 - test("returns base64url-encoded SHA-256 hash", async () => { 28 - const challenge = await adapter.computePkceChallenge("test-verifier"); 29 - expect(challenge).not.toContain("+"); 30 - expect(challenge).not.toContain("/"); 31 - expect(challenge).not.toContain("="); 32 - expect(challenge.length).toBeGreaterThan(0); 33 - }); 34 - 35 - test("produces consistent output for same input", async () => { 36 - const c1 = await adapter.computePkceChallenge("same-verifier"); 37 - const c2 = await adapter.computePkceChallenge("same-verifier"); 38 - expect(c1).toBe(c2); 39 - }); 40 - 41 - test("produces different output for different input", async () => { 42 - const c1 = await adapter.computePkceChallenge("verifier-a"); 43 - const c2 = await adapter.computePkceChallenge("verifier-b"); 44 - expect(c1).not.toBe(c2); 45 - }); 46 - }); 47 - 48 - describe("signEs256", () => { 49 - test("produces a 64-byte signature", async () => { 50 - const keyPair = await crypto.subtle.generateKey( 51 - { name: "ECDSA", namedCurve: "P-256" }, 52 - true, 53 - ["sign", "verify"], 54 - ); 55 - const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey); 56 - const payload = new TextEncoder().encode("test payload"); 57 - 58 - const sig = await adapter.signEs256(privateKey, payload); 59 - expect(sig).toBeInstanceOf(Uint8Array); 60 - expect(sig.length).toBe(64); 61 - }); 62 - 63 - test("produces verifiable signatures", async () => { 64 - const keyPair = await crypto.subtle.generateKey( 65 - { name: "ECDSA", namedCurve: "P-256" }, 66 - true, 67 - ["sign", "verify"], 68 - ); 69 - const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey); 70 - const payload = new TextEncoder().encode("verify me"); 71 - 72 - const sig = await adapter.signEs256(privateKey, payload); 73 - 74 - const valid = await crypto.subtle.verify( 75 - { name: "ECDSA", hash: "SHA-256" }, 76 - keyPair.publicKey, 77 - sig, 78 - payload, 79 - ); 80 - expect(valid).toBe(true); 81 - }); 82 - }); 83 - 84 - describe("sha256", () => { 85 - test("produces a 32-byte hash", async () => { 86 - const data = new TextEncoder().encode("hello world"); 87 - const hash = await adapter.sha256(data); 88 - expect(hash).toBeInstanceOf(Uint8Array); 89 - expect(hash.length).toBe(32); 90 - }); 91 - 92 - test("produces consistent output for same input", async () => { 93 - const data = new TextEncoder().encode("deterministic"); 94 - const h1 = await adapter.sha256(data); 95 - const h2 = await adapter.sha256(data); 96 - expect(Array.from(h1)).toEqual(Array.from(h2)); 97 - }); 98 - 99 - test("produces different output for different input", async () => { 100 - const h1 = await adapter.sha256(new TextEncoder().encode("input-a")); 101 - const h2 = await adapter.sha256(new TextEncoder().encode("input-b")); 102 - expect(Array.from(h1)).not.toEqual(Array.from(h2)); 103 - }); 104 - }); 105 - 106 - describe("getRandomValues", () => { 107 - test("returns Uint8Array of requested length", () => { 108 - const bytes = adapter.getRandomValues(16); 109 - expect(bytes).toBeInstanceOf(Uint8Array); 110 - expect(bytes.length).toBe(16); 111 - }); 112 - 113 - test("returns different values on each call", () => { 114 - const a = adapter.getRandomValues(32); 115 - const b = adapter.getRandomValues(32); 116 - expect(Array.from(a)).not.toEqual(Array.from(b)); 117 - }); 118 - }); 119 - });
+265 -61
packages/oauth-client-browser/src/browser-client.ts
··· 1 + import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; 2 + import { DidResolverCommon } from "@atproto-labs/did-resolver"; 3 + import type { DidDocument } from "@atproto/did"; 1 4 import { 2 - generateDpopProof, 3 5 HappyViewOAuthClient, 4 6 HappyViewSession, 7 + importJwk, 5 8 InvalidStateError, 9 + ResolutionError, 6 10 TokenExchangeError, 7 - type CryptoAdapter, 8 11 type StorageAdapter, 9 12 } from "@happyview/oauth-client"; 10 13 import { LocalStorageAdapter } from "./local-storage-adapter"; 11 - import { 12 - resolveAuthServerMetadata, 13 - resolveDidDocument, 14 - resolveHandleToDid, 15 - resolvePdsUrl, 16 - } from "./resolve"; 17 - import { WebCryptoAdapter } from "./web-crypto-adapter"; 18 14 19 15 export interface HappyViewBrowserClientOptions { 20 16 instanceUrl: string; 21 17 clientKey: string; 22 - crypto?: CryptoAdapter; 18 + scopes?: string; 23 19 storage?: StorageAdapter; 24 20 fetch?: typeof globalThis.fetch; 25 21 } ··· 27 23 interface PendingAuthState { 28 24 did: string; 29 25 provisionId: string; 30 - dpopKey: JsonWebKey; 26 + rawJwk: JsonWebKey; 31 27 provisionPkceVerifier: string; 32 28 authPkceVerifier: string; 33 29 pdsUrl: string; ··· 42 38 state: string; 43 39 } 44 40 45 - export class HappyViewBrowserClient extends HappyViewOAuthClient { 46 - private readonly _fetchFn: typeof globalThis.fetch; 41 + interface AuthServerMetadata { 42 + issuer: string; 43 + authorization_endpoint: string; 44 + token_endpoint: string; 45 + pushed_authorization_request_endpoint?: string; 46 + dpop_signing_alg_values_supported?: string[]; 47 + } 47 48 49 + export class HappyViewBrowserClient extends HappyViewOAuthClient { 50 + private readonly handleResolver: AtprotoDohHandleResolver; 51 + private readonly didResolver: DidResolverCommon; 52 + private readonly scopes: string; 48 53 constructor(options: HappyViewBrowserClientOptions) { 49 - const fetchFn = options.fetch ?? globalThis.fetch; 50 - const cryptoAdapter = options.crypto ?? new WebCryptoAdapter(); 54 + const fetchFn = options.fetch ?? (((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch); 51 55 const storageAdapter = options.storage ?? new LocalStorageAdapter(); 52 56 super({ 53 57 instanceUrl: options.instanceUrl, 54 58 clientKey: options.clientKey, 55 - crypto: cryptoAdapter, 56 59 storage: storageAdapter, 57 60 fetch: fetchFn, 58 61 }); 59 - this._fetchFn = fetchFn; 62 + 63 + this.scopes = options.scopes ?? "atproto"; 64 + this.handleResolver = new AtprotoDohHandleResolver({ 65 + dohEndpoint: "https://dns.google/resolve", 66 + fetch: fetchFn, 67 + }); 68 + this.didResolver = new DidResolverCommon({ fetch: fetchFn }); 60 69 } 61 70 62 71 async prepareLogin(handle: string): Promise<PrepareLoginResult> { 63 - const did = await resolveHandleToDid(handle, this._fetchFn); 64 - const doc = await resolveDidDocument(did, this._fetchFn); 65 - const pdsUrl = resolvePdsUrl(doc); 66 - const authMeta = await resolveAuthServerMetadata(pdsUrl, this._fetchFn); 72 + // Resolve handle → DID → DID document → PDS URL → auth server metadata 73 + const resolvedDid = await this.handleResolver.resolve(handle); 74 + if (!resolvedDid) { 75 + throw new ResolutionError(`Failed to resolve handle: ${handle}`); 76 + } 77 + const did = resolvedDid as string; 78 + 79 + const didDoc = await this.didResolver.resolve(resolvedDid); 80 + const pdsUrl = extractPdsUrl(didDoc); 81 + const authMeta = await this.fetchAuthServerMetadata(pdsUrl); 67 82 68 - const { provisionId, dpopKey, pkceVerifier: provisionPkceVerifier } = 83 + // Provision DPoP key from HappyView 84 + const { provisionId, rawJwk, pkceVerifier: provisionPkceVerifier } = 69 85 await this.provisionDpopKey(); 70 86 71 87 // Separate PKCE for the PDS authorization server 72 - const authPkceVerifier = await this.crypto.generatePkceVerifier(); 73 - const authPkceChallenge = 74 - await this.crypto.computePkceChallenge(authPkceVerifier); 88 + const authPkceVerifier = generatePkceVerifier(); 89 + const authPkceChallenge = await computePkceChallenge(authPkceVerifier); 75 90 76 - const stateBytes = this.crypto.getRandomValues(16); 91 + const stateBytes = crypto.getRandomValues(new Uint8Array(16)); 77 92 const state = Array.from(stateBytes, (b) => 78 93 b.toString(16).padStart(2, "0"), 79 94 ).join(""); ··· 81 96 const pendingState: PendingAuthState = { 82 97 did, 83 98 provisionId, 84 - dpopKey, 99 + rawJwk, 85 100 provisionPkceVerifier: provisionPkceVerifier!, 86 101 authPkceVerifier, 87 102 pdsUrl, ··· 94 109 JSON.stringify(pendingState), 95 110 ); 96 111 97 - const redirectUri = window.location.origin + "/oauth/callback"; 98 - const params = new URLSearchParams({ 112 + const { clientId, redirectUri } = this.resolveOAuthEndpoints(); 113 + 114 + const authParams = new URLSearchParams({ 99 115 response_type: "code", 100 - client_id: `${this.instanceUrl}/oauth-client-metadata.json`, 116 + client_id: clientId, 101 117 redirect_uri: redirectUri, 102 118 state, 103 - scope: "atproto", 119 + scope: this.scopes, 104 120 code_challenge: authPkceChallenge, 105 121 code_challenge_method: "S256", 122 + login_hint: handle, 106 123 }); 107 124 108 - const authorizationUrl = `${authMeta.authorization_endpoint}?${params}`; 125 + // ATProto requires Pushed Authorization Requests (PAR) 126 + const parEndpoint = authMeta.pushed_authorization_request_endpoint; 127 + if (parEndpoint) { 128 + const parResp = await this._fetch(parEndpoint, { 129 + method: "POST", 130 + headers: { 131 + "content-type": "application/x-www-form-urlencoded", 132 + }, 133 + body: authParams, 134 + }); 135 + 136 + if (!parResp.ok) { 137 + const err = await parResp.text(); 138 + throw new ResolutionError( 139 + `PAR request failed: ${parResp.status} ${err}`, 140 + ); 141 + } 142 + 143 + const parData = (await parResp.json()) as { request_uri: string }; 144 + const authorizationUrl = 145 + `${authMeta.authorization_endpoint}?` + 146 + new URLSearchParams({ 147 + client_id: clientId, 148 + request_uri: parData.request_uri, 149 + }); 150 + 151 + return { authorizationUrl, did, state }; 152 + } 153 + 154 + // Fallback: direct authorization URL (for servers that don't require PAR) 155 + const authorizationUrl = `${authMeta.authorization_endpoint}?${authParams}`; 109 156 110 157 return { authorizationUrl, did, state }; 111 158 } ··· 121 168 const state = params.get("state"); 122 169 123 170 if (!code || !state) { 124 - throw new InvalidStateError("Missing code or state in callback URL"); 171 + const error = params.get("error"); 172 + const errorDesc = params.get("error_description"); 173 + const raw = search ?? window.location.search; 174 + throw new InvalidStateError( 175 + `Missing code or state in callback URL. ` + 176 + `error=${error}, error_description=${errorDesc}, ` + 177 + `search=${raw}` 178 + ); 125 179 } 126 180 127 181 const pendingJson = await this.storage.get(`pending-auth:${state}`); 128 182 if (!pendingJson) { 129 - throw new InvalidStateError("No pending auth state found for this callback"); 183 + throw new InvalidStateError( 184 + "No pending auth state found for this callback", 185 + ); 130 186 } 131 187 const pending: PendingAuthState = JSON.parse(pendingJson); 132 188 133 - // Generate DPoP proof for the token endpoint (no ath — no access token yet) 134 - const dpopProof = await generateDpopProof(this.crypto, { 135 - privateKey: pending.dpopKey, 136 - method: "POST", 137 - url: pending.tokenEndpoint, 138 - }); 189 + // Import the stored JWK into a Key for DPoP proof generation 190 + const dpopKey = await importJwk(pending.rawJwk); 191 + // Build a plain public JWK object from the raw key (strip private "d" component) 192 + const { d: _, ...publicJwk } = pending.rawJwk; 139 193 140 - const redirectUri = window.location.origin + "/oauth/callback"; 141 - const tokenResp = await this._fetchFn(pending.tokenEndpoint, { 142 - method: "POST", 143 - headers: { 144 - "content-type": "application/x-www-form-urlencoded", 145 - dpop: dpopProof, 146 - }, 147 - body: new URLSearchParams({ 148 - grant_type: "authorization_code", 149 - code, 150 - redirect_uri: redirectUri, 151 - client_id: `${this.instanceUrl}/oauth-client-metadata.json`, 152 - code_verifier: pending.authPkceVerifier, 153 - }), 154 - }); 194 + const { clientId, redirectUri } = this.resolveOAuthEndpoints(); 155 195 156 - if (!tokenResp.ok) { 157 - const err = await tokenResp.text(); 196 + // Token exchange with DPoP nonce handling — the PDS may require a nonce 197 + // by responding with 400 + use_dpop_nonce error and a DPoP-Nonce header. 198 + let dpopNonce: string | undefined; 199 + let tokenResp!: Response; 200 + 201 + for (let attempt = 0; attempt < 2; attempt++) { 202 + const proof = await dpopKey.createJwt( 203 + { 204 + alg: "ES256", 205 + typ: "dpop+jwt", 206 + jwk: publicJwk as any, 207 + }, 208 + { 209 + htm: "POST", 210 + htu: pending.tokenEndpoint, 211 + iat: Math.floor(Date.now() / 1000), 212 + jti: randomHex(16), 213 + ...(dpopNonce ? { nonce: dpopNonce } : {}), 214 + }, 215 + ); 216 + 217 + tokenResp = await this._fetch(pending.tokenEndpoint, { 218 + method: "POST", 219 + headers: { 220 + "content-type": "application/x-www-form-urlencoded", 221 + dpop: proof, 222 + }, 223 + body: new URLSearchParams({ 224 + grant_type: "authorization_code", 225 + code, 226 + redirect_uri: redirectUri, 227 + client_id: clientId, 228 + code_verifier: pending.authPkceVerifier, 229 + }), 230 + }); 231 + 232 + // If the server requires a DPoP nonce, retry with it 233 + if (!tokenResp.ok && attempt === 0) { 234 + const nonceHeader = tokenResp.headers.get("dpop-nonce"); 235 + if (nonceHeader) { 236 + const errorBody = await tokenResp.text(); 237 + if (errorBody.includes("use_dpop_nonce")) { 238 + dpopNonce = nonceHeader; 239 + continue; 240 + } 241 + // Not a nonce error — throw 242 + throw new TokenExchangeError( 243 + `Token exchange failed: ${tokenResp.status} ${errorBody}`, 244 + tokenResp.status, 245 + errorBody, 246 + ); 247 + } 248 + } 249 + 250 + break; 251 + } 252 + 253 + if (!tokenResp!.ok) { 254 + const err = await tokenResp!.text(); 158 255 throw new TokenExchangeError( 159 - `Token exchange failed: ${tokenResp.status} ${err}`, 160 - tokenResp.status, 256 + `Token exchange failed: ${tokenResp!.status} ${err}`, 257 + tokenResp!.status, 161 258 err, 162 259 ); 163 260 } ··· 176 273 did: pending.did, 177 274 accessToken: tokens.access_token, 178 275 refreshToken: tokens.refresh_token, 179 - scopes: tokens.scope ?? "atproto", 276 + scopes: tokens.scope ?? this.scopes, 180 277 pdsUrl: pending.pdsUrl, 181 278 issuer: tokens.iss ?? pending.issuer, 182 - dpopKey: pending.dpopKey, 279 + dpopKey: pending.rawJwk, 183 280 }); 184 281 185 282 await this.storage.delete(`pending-auth:${state}`); ··· 190 287 async logout(did: string): Promise<void> { 191 288 await this.deleteSession(did); 192 289 } 290 + 291 + private resolveOAuthEndpoints(): { clientId: string; redirectUri: string } { 292 + const isLoopback = 293 + window.location.hostname === "127.0.0.1" || 294 + window.location.hostname === "[::1]" || 295 + window.location.hostname === "localhost"; 296 + 297 + if (isLoopback) { 298 + const params = new URLSearchParams({ scope: this.scopes }); 299 + return { 300 + clientId: `http://localhost?${params}`, 301 + redirectUri: `http://127.0.0.1:${window.location.port}/`, 302 + }; 303 + } 304 + 305 + return { 306 + clientId: `${this.instanceUrl}/oauth-client-metadata.json`, 307 + redirectUri: `${window.location.origin}/oauth/callback`, 308 + }; 309 + } 310 + 311 + private async fetchAuthServerMetadata( 312 + pdsUrl: string, 313 + ): Promise<AuthServerMetadata> { 314 + const base = pdsUrl.replace(/\/+$/, ""); 315 + 316 + const resourceResp = await this._fetch( 317 + `${base}/.well-known/oauth-protected-resource`, 318 + ); 319 + if (!resourceResp.ok) { 320 + throw new ResolutionError( 321 + `Failed to fetch protected resource metadata from ${pdsUrl}: ${resourceResp.status}`, 322 + ); 323 + } 324 + const resource = (await resourceResp.json()) as { 325 + authorization_servers?: string[]; 326 + }; 327 + const authServer = resource.authorization_servers?.[0]; 328 + if (!authServer) { 329 + throw new ResolutionError( 330 + `No authorization server found in protected resource metadata from ${pdsUrl}`, 331 + ); 332 + } 333 + 334 + const metaResp = await this._fetch( 335 + `${authServer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`, 336 + ); 337 + if (!metaResp.ok) { 338 + throw new ResolutionError( 339 + `Failed to fetch auth server metadata from ${authServer}: ${metaResp.status}`, 340 + ); 341 + } 342 + return metaResp.json(); 343 + } 344 + } 345 + 346 + function extractPdsUrl(doc: DidDocument): string { 347 + const services = doc.service ?? []; 348 + for (const service of services) { 349 + if ( 350 + service.id === "#atproto_pds" || 351 + (typeof service.id === "string" && service.id.endsWith("#atproto_pds")) 352 + ) { 353 + if (typeof service.serviceEndpoint === "string") { 354 + return service.serviceEndpoint; 355 + } 356 + throw new ResolutionError( 357 + `#atproto_pds service endpoint is not a string URL in DID document for ${doc.id}`, 358 + ); 359 + } 360 + } 361 + throw new ResolutionError( 362 + `No #atproto_pds service found in DID document for ${doc.id}`, 363 + ); 364 + } 365 + 366 + function randomHex(byteLength: number): string { 367 + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); 368 + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); 369 + } 370 + 371 + function generatePkceVerifier(): string { 372 + const bytes = crypto.getRandomValues(new Uint8Array(32)); 373 + let binary = ""; 374 + for (let i = 0; i < bytes.length; i++) { 375 + binary += String.fromCharCode(bytes[i]); 376 + } 377 + return btoa(binary) 378 + .replace(/\+/g, "-") 379 + .replace(/\//g, "_") 380 + .replace(/=+$/, ""); 381 + } 382 + 383 + async function computePkceChallenge(verifier: string): Promise<string> { 384 + const hash = await crypto.subtle.digest( 385 + "SHA-256", 386 + new TextEncoder().encode(verifier), 387 + ); 388 + const bytes = new Uint8Array(hash); 389 + let binary = ""; 390 + for (let i = 0; i < bytes.length; i++) { 391 + binary += String.fromCharCode(bytes[i]); 392 + } 393 + return btoa(binary) 394 + .replace(/\+/g, "-") 395 + .replace(/\//g, "_") 396 + .replace(/=+$/, ""); 193 397 }
+1 -11
packages/oauth-client-browser/src/index.ts
··· 7 7 MemoryStorage, 8 8 ResolutionError, 9 9 TokenExchangeError, 10 - base64urlEncode, 11 - generateDpopProof, 12 - type CryptoAdapter, 10 + importJwk, 13 11 type DpopProvision, 14 12 type HappyViewOAuthClientOptions, 15 13 type RegisterSessionParams, ··· 23 21 PrepareLoginResult, 24 22 } from "./browser-client"; 25 23 export { LocalStorageAdapter } from "./local-storage-adapter"; 26 - export { 27 - resolveAuthServerMetadata, 28 - resolveDidDocument, 29 - resolveHandleToDid, 30 - resolvePdsUrl, 31 - } from "./resolve"; 32 - export type { AuthServerMetadata, DidDocument } from "./resolve"; 33 - export { WebCryptoAdapter } from "./web-crypto-adapter";
-98
packages/oauth-client-browser/src/resolve.ts
··· 1 - import { ResolutionError } from "@happyview/oauth-client"; 2 - 3 - export interface DidDocument { 4 - id: string; 5 - service: Array<{ 6 - id: string; 7 - type: string; 8 - serviceEndpoint: string; 9 - }>; 10 - } 11 - 12 - export interface AuthServerMetadata { 13 - issuer: string; 14 - authorization_endpoint: string; 15 - token_endpoint: string; 16 - pushed_authorization_request_endpoint?: string; 17 - dpop_signing_alg_values_supported?: string[]; 18 - } 19 - 20 - export async function resolveHandleToDid( 21 - handle: string, 22 - fetchFn: typeof globalThis.fetch = globalThis.fetch, 23 - ): Promise<string> { 24 - try { 25 - const dnsUrl = `https://dns.google/dns-query?name=_atproto.${handle}&type=TXT`; 26 - const resp = await fetchFn(dnsUrl, { 27 - headers: { accept: "application/dns-json" }, 28 - }); 29 - if (resp.ok) { 30 - const data = await resp.json(); 31 - const answers = (data as any).Answer ?? []; 32 - for (const answer of answers) { 33 - const txt = String(answer.data).replace(/^"|"$/g, ""); 34 - if (txt.startsWith("did=")) { 35 - return txt.slice(4); 36 - } 37 - } 38 - } 39 - } catch { 40 - // DNS resolution failed, try HTTP fallback 41 - } 42 - 43 - const wellKnownUrl = `https://${handle}/.well-known/atproto-did`; 44 - const resp = await fetchFn(wellKnownUrl); 45 - if (!resp.ok) { 46 - throw new ResolutionError(`Failed to resolve handle ${handle}: ${resp.status}`); 47 - } 48 - const did = (await resp.text()).trim(); 49 - if (!did.startsWith("did:")) { 50 - throw new ResolutionError(`Invalid DID from handle resolution: ${did}`); 51 - } 52 - return did; 53 - } 54 - 55 - export async function resolveDidDocument( 56 - did: string, 57 - fetchFn: typeof globalThis.fetch = globalThis.fetch, 58 - ): Promise<DidDocument> { 59 - let url: string; 60 - if (did.startsWith("did:plc:")) { 61 - url = `https://plc.directory/${did}`; 62 - } else if (did.startsWith("did:web:")) { 63 - const methodSpecific = did.slice("did:web:".length); 64 - const parts = methodSpecific.split(":"); 65 - const host = decodeURIComponent(parts[0]); 66 - const path = parts.length > 1 ? "/" + parts.slice(1).map(decodeURIComponent).join("/") : ""; 67 - url = `https://${host}${path}/.well-known/did.json`; 68 - } else { 69 - throw new ResolutionError(`Unsupported DID method: ${did}`); 70 - } 71 - 72 - const resp = await fetchFn(url); 73 - if (!resp.ok) { 74 - throw new ResolutionError(`Failed to resolve DID ${did}: ${resp.status}`); 75 - } 76 - return resp.json(); 77 - } 78 - 79 - export function resolvePdsUrl(doc: DidDocument): string { 80 - for (const service of doc.service) { 81 - if (service.id === "#atproto_pds" || service.id.endsWith("#atproto_pds")) { 82 - return service.serviceEndpoint; 83 - } 84 - } 85 - throw new ResolutionError(`No #atproto_pds service found in DID document for ${doc.id}`); 86 - } 87 - 88 - export async function resolveAuthServerMetadata( 89 - pdsUrl: string, 90 - fetchFn: typeof globalThis.fetch = globalThis.fetch, 91 - ): Promise<AuthServerMetadata> { 92 - const url = `${pdsUrl.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`; 93 - const resp = await fetchFn(url); 94 - if (!resp.ok) { 95 - throw new ResolutionError(`Failed to fetch auth server metadata from ${pdsUrl}: ${resp.status}`); 96 - } 97 - return resp.json(); 98 - }
-45
packages/oauth-client-browser/src/web-crypto-adapter.ts
··· 1 - import { base64urlEncode, type CryptoAdapter } from "@happyview/oauth-client"; 2 - 3 - export class WebCryptoAdapter implements CryptoAdapter { 4 - async generatePkceVerifier(): Promise<string> { 5 - const bytes = new Uint8Array(32); 6 - crypto.getRandomValues(bytes); 7 - return base64urlEncode(bytes); 8 - } 9 - 10 - async computePkceChallenge(verifier: string): Promise<string> { 11 - const data = new TextEncoder().encode(verifier); 12 - const hash = await crypto.subtle.digest("SHA-256", data); 13 - return base64urlEncode(new Uint8Array(hash)); 14 - } 15 - 16 - async signEs256( 17 - privateKey: JsonWebKey, 18 - payload: Uint8Array, 19 - ): Promise<Uint8Array> { 20 - const key = await crypto.subtle.importKey( 21 - "jwk", 22 - privateKey, 23 - { name: "ECDSA", namedCurve: "P-256" }, 24 - false, 25 - ["sign"], 26 - ); 27 - const sig = await crypto.subtle.sign( 28 - { name: "ECDSA", hash: "SHA-256" }, 29 - key, 30 - payload as unknown as ArrayBuffer, 31 - ); 32 - return new Uint8Array(sig); 33 - } 34 - 35 - async sha256(data: Uint8Array): Promise<Uint8Array> { 36 - const hash = await crypto.subtle.digest("SHA-256", data as unknown as ArrayBuffer); 37 - return new Uint8Array(hash); 38 - } 39 - 40 - getRandomValues(length: number): Uint8Array { 41 - const bytes = new Uint8Array(length); 42 - crypto.getRandomValues(bytes); 43 - return bytes; 44 - } 45 - }
+4
packages/oauth-client/package.json
··· 30 30 "test": "bun test", 31 31 "typecheck": "tsc --noEmit" 32 32 }, 33 + "dependencies": { 34 + "@atproto/jwk": "^0.6.0", 35 + "@atproto/jwk-webcrypto": "^0.2.0" 36 + }, 33 37 "devDependencies": { 34 38 "@semantic-release/commit-analyzer": "^13.0.1", 35 39 "@semantic-release/github": "^12.0.6",
+89 -42
packages/oauth-client/src/__tests__/client.test.ts
··· 2 2 import { HappyViewOAuthClient } from "../client"; 3 3 import { ApiError } from "../errors"; 4 4 import { MemoryStorage } from "../storage"; 5 - import type { CryptoAdapter } from "../types"; 6 - 7 - const testCrypto: CryptoAdapter = { 8 - generatePkceVerifier: async () => "test-verifier-1234567890", 9 - computePkceChallenge: async (v: string) => `challenge-of-${v}`, 10 - signEs256: async () => new Uint8Array(64), 11 - sha256: async (data: Uint8Array) => { 12 - const hash = await crypto.subtle.digest("SHA-256", data); 13 - return new Uint8Array(hash); 14 - }, 15 - getRandomValues: (length: number) => { 16 - const bytes = new Uint8Array(length); 17 - crypto.getRandomValues(bytes); 18 - return bytes; 19 - }, 20 - }; 21 5 22 6 function createMockFetch(responses: Array<{ status: number; body: unknown }>) { 23 7 let callIndex = 0; ··· 26 10 const fetchFn = mock(async (input: RequestInfo | URL, init?: RequestInit) => { 27 11 const url = input instanceof URL ? input.toString() : String(input); 28 12 calls.push({ url, init: init ?? {} }); 29 - const resp = responses[callIndex] ?? { status: 500, body: { error: "no more mocked responses" } }; 13 + const resp = responses[callIndex] ?? { 14 + status: 500, 15 + body: { error: "no more mocked responses" }, 16 + }; 30 17 callIndex++; 31 18 return new Response(JSON.stringify(resp.body), { status: resp.status }); 32 19 }); 33 20 34 21 return { fetchFn, calls }; 22 + } 23 + 24 + async function generateTestJwk(): Promise<JsonWebKey> { 25 + const keyPair = await crypto.subtle.generateKey( 26 + { name: "ECDSA", namedCurve: "P-256" }, 27 + true, 28 + ["sign", "verify"], 29 + ); 30 + const jwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); 31 + // Remove key_ops so importJwk can re-import with its own usage constraints 32 + delete jwk.key_ops; 33 + return jwk; 35 34 } 36 35 37 36 function createClient(overrides?: { ··· 43 42 instanceUrl: "https://happyview.example.com", 44 43 clientKey: "hvc_testkey", 45 44 clientSecret: overrides?.clientSecret, 46 - crypto: testCrypto, 47 45 storage: overrides?.storage ?? new MemoryStorage(), 48 46 fetch: overrides?.fetchFn, 49 47 }); ··· 52 50 describe("HappyViewOAuthClient", () => { 53 51 describe("provisionDpopKey", () => { 54 52 test("calls POST /oauth/dpop-keys with client credentials in headers", async () => { 53 + const testJwk = await generateTestJwk(); 55 54 const { fetchFn, calls } = createMockFetch([ 56 55 { 57 56 status: 201, 58 57 body: { 59 58 provision_id: "hvp_abc123", 60 - dpop_key: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 59 + dpop_key: testJwk, 61 60 }, 62 61 }, 63 62 ]); ··· 65 64 const client = createClient({ fetchFn, clientSecret: "hvs_secret" }); 66 65 const result = await client.provisionDpopKey(); 67 66 68 - expect(calls[0].url).toBe("https://happyview.example.com/oauth/dpop-keys"); 67 + expect(calls[0].url).toBe( 68 + "https://happyview.example.com/oauth/dpop-keys", 69 + ); 69 70 const headers = new Headers(calls[0].init.headers); 70 71 expect(headers.get("x-client-key")).toBe("hvc_testkey"); 71 72 expect(headers.get("x-client-secret")).toBe("hvs_secret"); 72 73 expect(result.provisionId).toBe("hvp_abc123"); 73 - expect(result.dpopKey.kty).toBe("EC"); 74 + expect(result.dpopKey).toBeDefined(); 75 + expect(result.rawJwk).toBeDefined(); 74 76 }); 75 77 76 78 test("includes PKCE challenge for public clients and returns verifier", async () => { 79 + const testJwk = await generateTestJwk(); 77 80 const { fetchFn, calls } = createMockFetch([ 78 81 { 79 82 status: 201, 80 83 body: { 81 84 provision_id: "hvp_public", 82 - dpop_key: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 85 + dpop_key: testJwk, 83 86 }, 84 87 }, 85 88 ]); ··· 88 91 const result = await client.provisionDpopKey(); 89 92 90 93 const body = JSON.parse(calls[0].init.body as string); 91 - expect(body.pkce_challenge).toBe("challenge-of-test-verifier-1234567890"); 92 - expect(result.pkceVerifier).toBe("test-verifier-1234567890"); 94 + expect(body.pkce_challenge).toBeDefined(); 95 + expect(typeof body.pkce_challenge).toBe("string"); 96 + expect(result.pkceVerifier).toBeDefined(); 97 + expect(typeof result.pkceVerifier).toBe("string"); 93 98 }); 94 99 95 100 test("does not include PKCE for confidential clients", async () => { 101 + const testJwk = await generateTestJwk(); 96 102 const { fetchFn, calls } = createMockFetch([ 97 103 { 98 104 status: 201, 99 105 body: { 100 106 provision_id: "hvp_conf", 101 - dpop_key: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 107 + dpop_key: testJwk, 102 108 }, 103 109 }, 104 110 ]); ··· 119 125 const client = createClient({ fetchFn }); 120 126 try { 121 127 await client.provisionDpopKey(); 122 - expect(true).toBe(false); // should not reach 128 + expect(true).toBe(false); 123 129 } catch (err) { 124 130 expect(err).toBeInstanceOf(ApiError); 125 131 expect((err as ApiError).status).toBe(400); ··· 130 136 131 137 describe("registerSession", () => { 132 138 test("calls POST /oauth/sessions and returns a HappyViewSession", async () => { 139 + const testJwk = await generateTestJwk(); 133 140 const { fetchFn, calls } = createMockFetch([ 134 141 { 135 142 status: 201, ··· 138 145 ]); 139 146 140 147 const storage = new MemoryStorage(); 141 - const client = createClient({ fetchFn, clientSecret: "hvs_sec", storage }); 148 + const client = createClient({ 149 + fetchFn, 150 + clientSecret: "hvs_sec", 151 + storage, 152 + }); 142 153 const session = await client.registerSession({ 143 154 provisionId: "hvp_abc", 144 155 did: "did:plc:testuser", 145 156 accessToken: "at_token", 146 157 scopes: "atproto", 147 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 158 + dpopKey: testJwk, 148 159 }); 149 160 150 - expect(calls[0].url).toBe("https://happyview.example.com/oauth/sessions"); 161 + expect(calls[0].url).toBe( 162 + "https://happyview.example.com/oauth/sessions", 163 + ); 151 164 expect(session.did).toBe("did:plc:testuser"); 152 165 }); 153 166 154 167 test("persists session and last active DID to storage", async () => { 168 + const testJwk = await generateTestJwk(); 155 169 const { fetchFn } = createMockFetch([ 156 170 { 157 171 status: 201, ··· 160 174 ]); 161 175 162 176 const storage = new MemoryStorage(); 163 - const client = createClient({ fetchFn, clientSecret: "hvs_sec", storage }); 177 + const client = createClient({ 178 + fetchFn, 179 + clientSecret: "hvs_sec", 180 + storage, 181 + }); 164 182 await client.registerSession({ 165 183 provisionId: "hvp_abc", 166 184 did: "did:plc:testuser", 167 185 accessToken: "at_token", 168 186 scopes: "atproto", 169 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 187 + dpopKey: testJwk, 170 188 }); 171 189 172 190 const stored = await storage.get("happyview:session:did:plc:testuser"); ··· 187 205 ]); 188 206 189 207 const storage = new MemoryStorage(); 190 - await storage.set("happyview:session:did:plc:testuser", JSON.stringify({ did: "did:plc:testuser" })); 208 + await storage.set( 209 + "happyview:session:did:plc:testuser", 210 + JSON.stringify({ did: "did:plc:testuser" }), 211 + ); 191 212 await storage.set("happyview:last-active-did", "did:plc:testuser"); 192 213 193 - const client = createClient({ fetchFn, clientSecret: "hvs_sec", storage }); 214 + const client = createClient({ 215 + fetchFn, 216 + clientSecret: "hvs_sec", 217 + storage, 218 + }); 194 219 await client.deleteSession("did:plc:testuser"); 195 220 196 - expect(calls[0].url).toBe("https://happyview.example.com/oauth/sessions/did:plc:testuser"); 221 + expect(calls[0].url).toBe( 222 + "https://happyview.example.com/oauth/sessions/did:plc:testuser", 223 + ); 197 224 expect(calls[0].init.method).toBe("DELETE"); 198 225 }); 199 226 ··· 201 228 const { fetchFn } = createMockFetch([{ status: 204, body: null }]); 202 229 203 230 const storage = new MemoryStorage(); 204 - await storage.set("happyview:session:did:plc:testuser", JSON.stringify({ did: "did:plc:testuser" })); 231 + await storage.set( 232 + "happyview:session:did:plc:testuser", 233 + JSON.stringify({ did: "did:plc:testuser" }), 234 + ); 205 235 await storage.set("happyview:last-active-did", "did:plc:testuser"); 206 236 207 - const client = createClient({ fetchFn, clientSecret: "hvs_sec", storage }); 237 + const client = createClient({ 238 + fetchFn, 239 + clientSecret: "hvs_sec", 240 + storage, 241 + }); 208 242 await client.deleteSession("did:plc:testuser"); 209 243 210 - expect(await storage.get("happyview:session:did:plc:testuser")).toBeNull(); 244 + expect( 245 + await storage.get("happyview:session:did:plc:testuser"), 246 + ).toBeNull(); 211 247 expect(await storage.get("happyview:last-active-did")).toBeNull(); 212 248 }); 213 249 ··· 215 251 const { fetchFn } = createMockFetch([{ status: 204, body: null }]); 216 252 217 253 const storage = new MemoryStorage(); 218 - await storage.set("happyview:session:did:plc:other", JSON.stringify({ did: "did:plc:other" })); 254 + await storage.set( 255 + "happyview:session:did:plc:other", 256 + JSON.stringify({ did: "did:plc:other" }), 257 + ); 219 258 await storage.set("happyview:last-active-did", "did:plc:testuser"); 220 259 221 - const client = createClient({ fetchFn, clientSecret: "hvs_sec", storage }); 260 + const client = createClient({ 261 + fetchFn, 262 + clientSecret: "hvs_sec", 263 + storage, 264 + }); 222 265 await client.deleteSession("did:plc:other"); 223 266 224 267 expect(await storage.get("happyview:session:did:plc:other")).toBeNull(); 225 - expect(await storage.get("happyview:last-active-did")).toBe("did:plc:testuser"); 268 + expect(await storage.get("happyview:last-active-did")).toBe( 269 + "did:plc:testuser", 270 + ); 226 271 }); 227 272 }); 228 273 ··· 234 279 }); 235 280 236 281 test("restores session from storage", async () => { 282 + const testJwk = await generateTestJwk(); 237 283 const storage = new MemoryStorage(); 238 284 await storage.set( 239 285 "happyview:session:did:plc:testuser", 240 286 JSON.stringify({ 241 287 did: "did:plc:testuser", 242 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 288 + dpopKey: testJwk, 243 289 accessToken: "at_stored", 244 290 clientKey: "hvc_testkey", 245 291 instanceUrl: "https://happyview.example.com", ··· 261 307 }); 262 308 263 309 test("restores last active session", async () => { 310 + const testJwk = await generateTestJwk(); 264 311 const storage = new MemoryStorage(); 265 312 await storage.set("happyview:last-active-did", "did:plc:testuser"); 266 313 await storage.set( 267 314 "happyview:session:did:plc:testuser", 268 315 JSON.stringify({ 269 316 did: "did:plc:testuser", 270 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 317 + dpopKey: testJwk, 271 318 accessToken: "at_stored", 272 319 clientKey: "hvc_testkey", 273 320 instanceUrl: "https://happyview.example.com",
-217
packages/oauth-client/src/__tests__/dpop-proof.test.ts
··· 1 - import { describe, expect, test } from "bun:test"; 2 - import { generateDpopProof } from "../dpop-proof"; 3 - import type { CryptoAdapter } from "../types"; 4 - 5 - const testCrypto: CryptoAdapter = { 6 - generatePkceVerifier: async () => "not-used-here", 7 - computePkceChallenge: async () => "not-used-here", 8 - signEs256: async ( 9 - privateKey: JsonWebKey, 10 - payload: Uint8Array, 11 - ): Promise<Uint8Array> => { 12 - const key = await crypto.subtle.importKey( 13 - "jwk", 14 - privateKey, 15 - { name: "ECDSA", namedCurve: "P-256" }, 16 - false, 17 - ["sign"], 18 - ); 19 - const sig = await crypto.subtle.sign( 20 - { name: "ECDSA", hash: "SHA-256" }, 21 - key, 22 - payload, 23 - ); 24 - return new Uint8Array(sig); 25 - }, 26 - sha256: async (data: Uint8Array): Promise<Uint8Array> => { 27 - const hash = await crypto.subtle.digest("SHA-256", data); 28 - return new Uint8Array(hash); 29 - }, 30 - getRandomValues: (length: number): Uint8Array => { 31 - const bytes = new Uint8Array(length); 32 - crypto.getRandomValues(bytes); 33 - return bytes; 34 - }, 35 - }; 36 - 37 - async function generateTestKeyPair(): Promise<{ 38 - privateKey: JsonWebKey; 39 - publicKey: JsonWebKey; 40 - }> { 41 - const keyPair = await crypto.subtle.generateKey( 42 - { name: "ECDSA", namedCurve: "P-256" }, 43 - true, 44 - ["sign", "verify"], 45 - ); 46 - const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey); 47 - const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 48 - return { privateKey, publicKey }; 49 - } 50 - 51 - function base64urlDecode(str: string): Uint8Array { 52 - const padded = str + "=".repeat((4 - (str.length % 4)) % 4); 53 - const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); 54 - return Uint8Array.from(binary, (c) => c.charCodeAt(0)); 55 - } 56 - 57 - describe("generateDpopProof", () => { 58 - test("produces a valid 3-part JWT", async () => { 59 - const { privateKey } = await generateTestKeyPair(); 60 - const proof = await generateDpopProof(testCrypto, { 61 - privateKey, 62 - method: "POST", 63 - url: "https://pds.example.com/xrpc/com.atproto.repo.createRecord", 64 - accessToken: "test-access-token", 65 - }); 66 - 67 - const parts = proof.split("."); 68 - expect(parts).toHaveLength(3); 69 - }); 70 - 71 - test("header has correct alg, typ, and jwk", async () => { 72 - const { privateKey, publicKey } = await generateTestKeyPair(); 73 - const proof = await generateDpopProof(testCrypto, { 74 - privateKey, 75 - method: "GET", 76 - url: "https://pds.example.com/xrpc/test.method", 77 - accessToken: "tok", 78 - }); 79 - 80 - const header = JSON.parse( 81 - new TextDecoder().decode(base64urlDecode(proof.split(".")[0])), 82 - ); 83 - expect(header.alg).toBe("ES256"); 84 - expect(header.typ).toBe("dpop+jwt"); 85 - expect(header.jwk.kty).toBe("EC"); 86 - expect(header.jwk.crv).toBe("P-256"); 87 - expect(header.jwk.x).toBe(publicKey.x); 88 - expect(header.jwk.y).toBe(publicKey.y); 89 - expect(header.jwk.d).toBeUndefined(); 90 - }); 91 - 92 - test("payload has htm, htu, iat, ath, jti", async () => { 93 - const { privateKey } = await generateTestKeyPair(); 94 - const proof = await generateDpopProof(testCrypto, { 95 - privateKey, 96 - method: "POST", 97 - url: "https://pds.example.com/xrpc/test.method", 98 - accessToken: "my-access-token", 99 - }); 100 - 101 - const payload = JSON.parse( 102 - new TextDecoder().decode(base64urlDecode(proof.split(".")[1])), 103 - ); 104 - expect(payload.htm).toBe("POST"); 105 - expect(payload.htu).toBe("https://pds.example.com/xrpc/test.method"); 106 - expect(typeof payload.iat).toBe("number"); 107 - expect(typeof payload.ath).toBe("string"); 108 - expect(typeof payload.jti).toBe("string"); 109 - expect(payload.ath.length).toBeGreaterThan(0); 110 - expect(payload.jti.length).toBeGreaterThan(0); 111 - }); 112 - 113 - test("ath is base64url(SHA-256(access_token))", async () => { 114 - const { privateKey } = await generateTestKeyPair(); 115 - const accessToken = "specific-test-token"; 116 - const proof = await generateDpopProof(testCrypto, { 117 - privateKey, 118 - method: "GET", 119 - url: "https://example.com/xrpc/test", 120 - accessToken, 121 - }); 122 - 123 - const payload = JSON.parse( 124 - new TextDecoder().decode(base64urlDecode(proof.split(".")[1])), 125 - ); 126 - 127 - const tokenBytes = new TextEncoder().encode(accessToken); 128 - const hashBuf = await crypto.subtle.digest("SHA-256", tokenBytes); 129 - const hashArr = new Uint8Array(hashBuf); 130 - let binary = ""; 131 - for (let i = 0; i < hashArr.length; i++) { 132 - binary += String.fromCharCode(hashArr[i]); 133 - } 134 - const expected = btoa(binary) 135 - .replace(/\+/g, "-") 136 - .replace(/\//g, "_") 137 - .replace(/=+$/, ""); 138 - 139 - expect(payload.ath).toBe(expected); 140 - }); 141 - 142 - test("includes nonce when provided", async () => { 143 - const { privateKey } = await generateTestKeyPair(); 144 - const proof = await generateDpopProof(testCrypto, { 145 - privateKey, 146 - method: "GET", 147 - url: "https://example.com/xrpc/test", 148 - accessToken: "tok", 149 - nonce: "server-nonce-123", 150 - }); 151 - 152 - const payload = JSON.parse( 153 - new TextDecoder().decode(base64urlDecode(proof.split(".")[1])), 154 - ); 155 - expect(payload.nonce).toBe("server-nonce-123"); 156 - }); 157 - 158 - test("omits ath when accessToken is not provided", async () => { 159 - const { privateKey } = await generateTestKeyPair(); 160 - const proof = await generateDpopProof(testCrypto, { 161 - privateKey, 162 - method: "POST", 163 - url: "https://pds.example.com/oauth/token", 164 - }); 165 - 166 - const payload = JSON.parse( 167 - new TextDecoder().decode(base64urlDecode(proof.split(".")[1])), 168 - ); 169 - expect(payload.ath).toBeUndefined(); 170 - expect(payload.htm).toBe("POST"); 171 - expect(payload.htu).toBe("https://pds.example.com/oauth/token"); 172 - }); 173 - 174 - test("omits nonce when not provided", async () => { 175 - const { privateKey } = await generateTestKeyPair(); 176 - const proof = await generateDpopProof(testCrypto, { 177 - privateKey, 178 - method: "GET", 179 - url: "https://example.com/xrpc/test", 180 - accessToken: "tok", 181 - }); 182 - 183 - const payload = JSON.parse( 184 - new TextDecoder().decode(base64urlDecode(proof.split(".")[1])), 185 - ); 186 - expect(payload.nonce).toBeUndefined(); 187 - }); 188 - 189 - test("signature is verifiable", async () => { 190 - const { privateKey, publicKey } = await generateTestKeyPair(); 191 - const proof = await generateDpopProof(testCrypto, { 192 - privateKey, 193 - method: "POST", 194 - url: "https://pds.example.com/xrpc/test.method", 195 - accessToken: "my-token", 196 - }); 197 - 198 - const [headerB64, payloadB64, sigB64] = proof.split("."); 199 - const message = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 200 - const signature = base64urlDecode(sigB64); 201 - 202 - const verifyKey = await crypto.subtle.importKey( 203 - "jwk", 204 - publicKey, 205 - { name: "ECDSA", namedCurve: "P-256" }, 206 - false, 207 - ["verify"], 208 - ); 209 - const valid = await crypto.subtle.verify( 210 - { name: "ECDSA", hash: "SHA-256" }, 211 - verifyKey, 212 - signature, 213 - message, 214 - ); 215 - expect(valid).toBe(true); 216 - }); 217 - });
+50 -65
packages/oauth-client/src/__tests__/session.test.ts
··· 1 1 import { describe, expect, mock, test } from "bun:test"; 2 + import { WebcryptoKey } from "@atproto/jwk-webcrypto"; 2 3 import { HappyViewSession } from "../session"; 3 - import type { CryptoAdapter } from "../types"; 4 4 5 - const testCrypto: CryptoAdapter = { 6 - generatePkceVerifier: async () => "not-used", 7 - computePkceChallenge: async () => "not-used", 8 - signEs256: async (_key: JsonWebKey, payload: Uint8Array) => { 9 - return new Uint8Array(64); 10 - }, 11 - sha256: async (data: Uint8Array) => { 12 - const hash = await crypto.subtle.digest("SHA-256", data); 13 - return new Uint8Array(hash); 14 - }, 15 - getRandomValues: (length: number) => { 16 - const bytes = new Uint8Array(length); 17 - crypto.getRandomValues(bytes); 18 - return bytes; 19 - }, 20 - }; 5 + async function generateTestKey(): Promise<WebcryptoKey> { 6 + const keyPair = await crypto.subtle.generateKey( 7 + { name: "ECDSA", namedCurve: "P-256" }, 8 + true, 9 + ["sign", "verify"], 10 + ); 11 + return WebcryptoKey.fromKeypair(keyPair); 12 + } 21 13 22 14 function createSession(overrides?: { 23 15 instanceUrl?: string; 16 + dpopKey?: WebcryptoKey; 24 17 fetchMock?: typeof globalThis.fetch; 25 18 }) { 26 - return new HappyViewSession({ 27 - did: "did:plc:testuser", 28 - dpopKey: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, 29 - accessToken: "test-access-token", 30 - clientKey: "hvc_testkey", 31 - instanceUrl: overrides?.instanceUrl ?? "https://happyview.example.com", 32 - crypto: testCrypto, 33 - fetch: overrides?.fetchMock, 34 - }); 19 + return async () => { 20 + const dpopKey = overrides?.dpopKey ?? (await generateTestKey()); 21 + return new HappyViewSession({ 22 + did: "did:plc:testuser", 23 + dpopKey, 24 + accessToken: "test-access-token", 25 + clientKey: "hvc_testkey", 26 + instanceUrl: overrides?.instanceUrl ?? "https://happyview.example.com", 27 + fetch: overrides?.fetchMock, 28 + }); 29 + }; 35 30 } 36 31 37 32 describe("HappyViewSession", () => { 38 - test("exposes did property", () => { 39 - const session = createSession(); 33 + test("exposes did property", async () => { 34 + const session = await createSession()(); 40 35 expect(session.did).toBe("did:plc:testuser"); 41 36 }); 42 37 43 38 test("fetchHandler prepends instanceUrl to relative paths", async () => { 44 39 let capturedUrl: string | undefined; 45 - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { 40 + const fetchMock = mock(async (input: RequestInfo | URL) => { 46 41 capturedUrl = input instanceof URL ? input.toString() : String(input); 47 42 return new Response(JSON.stringify({ ok: true }), { status: 200 }); 48 43 }); 49 44 50 - const session = createSession({ fetchMock }); 45 + const session = await createSession({ fetchMock })(); 51 46 await session.fetchHandler( 52 47 "/xrpc/com.example.test.getStuff?param=value", 53 48 {}, ··· 60 55 61 56 test("fetchHandler passes through absolute URLs without prepending", async () => { 62 57 let capturedUrl: string | undefined; 63 - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { 58 + const fetchMock = mock(async (input: RequestInfo | URL) => { 64 59 capturedUrl = input instanceof URL ? input.toString() : String(input); 65 60 return new Response("{}", { status: 200 }); 66 61 }); 67 62 68 - const session = createSession({ fetchMock }); 63 + const session = await createSession({ fetchMock })(); 69 64 await session.fetchHandler( 70 65 "https://other-service.example.com/xrpc/test.method", 71 66 {}, ··· 78 73 79 74 test("fetchHandler adds Authorization DPoP header", async () => { 80 75 let capturedInit: RequestInit | undefined; 81 - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { 76 + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { 82 77 capturedInit = init; 83 78 return new Response("{}", { status: 200 }); 84 79 }); 85 80 86 - const session = createSession({ fetchMock }); 81 + const session = await createSession({ fetchMock })(); 87 82 await session.fetchHandler("/xrpc/test.method", {}); 88 83 89 84 const headers = new Headers(capturedInit?.headers); 90 85 expect(headers.get("authorization")).toBe("DPoP test-access-token"); 91 86 }); 92 87 93 - test("fetchHandler adds DPoP proof header", async () => { 88 + test("fetchHandler adds DPoP proof header as valid JWT", async () => { 94 89 let capturedInit: RequestInit | undefined; 95 - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { 90 + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { 96 91 capturedInit = init; 97 92 return new Response("{}", { status: 200 }); 98 93 }); 99 94 100 - const session = createSession({ fetchMock }); 95 + const session = await createSession({ fetchMock })(); 101 96 await session.fetchHandler("/xrpc/test.method", {}); 102 97 103 98 const headers = new Headers(capturedInit?.headers); ··· 108 103 109 104 test("fetchHandler adds X-Client-Key header", async () => { 110 105 let capturedInit: RequestInit | undefined; 111 - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { 106 + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { 112 107 capturedInit = init; 113 108 return new Response("{}", { status: 200 }); 114 109 }); 115 110 116 - const session = createSession({ fetchMock }); 111 + const session = await createSession({ fetchMock })(); 117 112 await session.fetchHandler("/xrpc/test.method", {}); 118 113 119 114 const headers = new Headers(capturedInit?.headers); ··· 122 117 123 118 test("fetchHandler preserves existing headers from init", async () => { 124 119 let capturedInit: RequestInit | undefined; 125 - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { 120 + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { 126 121 capturedInit = init; 127 122 return new Response("{}", { status: 200 }); 128 123 }); 129 124 130 - const session = createSession({ fetchMock }); 125 + const session = await createSession({ fetchMock })(); 131 126 await session.fetchHandler("/xrpc/test.method", { 132 127 headers: { "content-type": "application/json" }, 133 128 }); ··· 138 133 }); 139 134 140 135 test("fetchHandler stores DPoP-Nonce from response", async () => { 141 - let callCount = 0; 142 - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { 143 - callCount++; 136 + const fetchMock = mock(async () => { 144 137 const headers = new Headers(); 145 - if (callCount === 1) { 146 - headers.set("dpop-nonce", "server-nonce-abc"); 147 - } 138 + headers.set("dpop-nonce", "server-nonce-abc"); 148 139 return new Response("{}", { status: 200, headers }); 149 140 }); 150 141 151 - const session = createSession({ fetchMock }); 142 + const session = await createSession({ fetchMock })(); 152 143 await session.fetchHandler("/xrpc/test.method", {}); 153 144 expect((session as any).dpopNonce).toBe("server-nonce-abc"); 154 145 }); ··· 156 147 test("fetchHandler includes stored nonce in subsequent DPoP proofs", async () => { 157 148 const capturedInits: RequestInit[] = []; 158 149 let callCount = 0; 159 - const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { 150 + const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { 160 151 capturedInits.push(init ?? {}); 161 152 callCount++; 162 153 const headers = new Headers(); ··· 166 157 return new Response("{}", { status: 200, headers }); 167 158 }); 168 159 169 - const session = createSession({ fetchMock }); 160 + const session = await createSession({ fetchMock })(); 170 161 171 - // First request — no nonce yet 172 162 await session.fetchHandler("/xrpc/test.method", {}); 173 - 174 - // Second request — should include nonce from first response 175 163 await session.fetchHandler("/xrpc/test.method2", {}); 176 164 177 - const firstDpop = new Headers(capturedInits[0].headers).get("dpop")!; 178 - const secondDpop = new Headers(capturedInits[1].headers).get("dpop")!; 179 - 180 - function base64urlDecode(str: string): Uint8Array { 181 - const padded = str + "=".repeat((4 - (str.length % 4)) % 4); 165 + function decodeJwtPayload(jwt: string): Record<string, unknown> { 166 + const payloadB64 = jwt.split(".")[1]; 167 + const padded = payloadB64 + "=".repeat((4 - (payloadB64.length % 4)) % 4); 182 168 const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); 183 - return Uint8Array.from(binary, (c) => c.charCodeAt(0)); 169 + return JSON.parse(binary); 184 170 } 185 171 186 - const firstPayload = JSON.parse( 187 - new TextDecoder().decode(base64urlDecode(firstDpop.split(".")[1])), 188 - ); 189 - const secondPayload = JSON.parse( 190 - new TextDecoder().decode(base64urlDecode(secondDpop.split(".")[1])), 191 - ); 172 + const firstDpop = new Headers(capturedInits[0].headers).get("dpop")!; 173 + const secondDpop = new Headers(capturedInits[1].headers).get("dpop")!; 174 + 175 + const firstPayload = decodeJwtPayload(firstDpop); 176 + const secondPayload = decodeJwtPayload(secondDpop); 192 177 193 178 expect(firstPayload.nonce).toBeUndefined(); 194 179 expect(secondPayload.nonce).toBe("server-nonce-xyz");
-27
packages/oauth-client/src/__tests__/types.test.ts
··· 1 1 import { describe, expect, test } from "bun:test"; 2 2 import type { 3 - CryptoAdapter, 4 3 DpopProvision, 5 4 HappyViewOAuthClientOptions, 6 5 RegisterSessionParams, ··· 13 12 instanceUrl: "https://example.com", 14 13 clientKey: "hvc_test", 15 14 clientSecret: "hvs_secret", 16 - crypto: { 17 - generatePkceVerifier: async () => "verifier", 18 - computePkceChallenge: async () => "challenge", 19 - signEs256: async () => new Uint8Array(64), 20 - sha256: async () => new Uint8Array(32), 21 - getRandomValues: (n) => new Uint8Array(n), 22 - }, 23 15 }; 24 16 expect(opts.clientKey).toBe("hvc_test"); 25 17 expect(opts.clientSecret).toBe("hvs_secret"); ··· 29 21 const opts: HappyViewOAuthClientOptions = { 30 22 instanceUrl: "https://example.com", 31 23 clientKey: "hvc_test", 32 - crypto: { 33 - generatePkceVerifier: async () => "verifier", 34 - computePkceChallenge: async () => "challenge", 35 - signEs256: async () => new Uint8Array(64), 36 - sha256: async () => new Uint8Array(32), 37 - getRandomValues: (n) => new Uint8Array(n), 38 - }, 39 24 }; 40 25 expect(opts.clientSecret).toBeUndefined(); 41 26 }); ··· 70 55 expect(storage.get).toBeFunction(); 71 56 }); 72 57 73 - test("CryptoAdapter interface shape", () => { 74 - const adapter: CryptoAdapter = { 75 - generatePkceVerifier: async () => "verifier", 76 - computePkceChallenge: async () => "challenge", 77 - signEs256: async () => new Uint8Array(64), 78 - sha256: async () => new Uint8Array(32), 79 - getRandomValues: (n) => new Uint8Array(n), 80 - }; 81 - expect(adapter.signEs256).toBeFunction(); 82 - expect(adapter.sha256).toBeFunction(); 83 - expect(adapter.getRandomValues).toBeFunction(); 84 - }); 85 58 });
+46 -13
packages/oauth-client/src/client.ts
··· 1 + import type { Key } from "@atproto/jwk"; 1 2 import { ApiError } from "./errors"; 3 + import { importJwk } from "./import-jwk"; 2 4 import { HappyViewSession } from "./session"; 3 5 import { MemoryStorage } from "./storage"; 4 6 import type { 5 - CryptoAdapter, 6 7 HappyViewOAuthClientOptions, 7 8 ProvisionKeyResponse, 8 9 RegisterSessionParams, ··· 17 18 export class HappyViewOAuthClient { 18 19 protected readonly instanceUrl: string; 19 20 protected readonly clientKey: string; 20 - protected readonly crypto: CryptoAdapter; 21 21 protected readonly storage: StorageAdapter; 22 22 private readonly clientSecret: string | undefined; 23 - private readonly _fetch: typeof globalThis.fetch; 23 + protected readonly _fetch: typeof globalThis.fetch; 24 24 25 25 constructor( 26 26 options: HappyViewOAuthClientOptions & { ··· 30 30 this.instanceUrl = options.instanceUrl.replace(/\/+$/, ""); 31 31 this.clientKey = options.clientKey; 32 32 this.clientSecret = options.clientSecret; 33 - this.crypto = options.crypto; 34 33 this.storage = options.storage ?? new MemoryStorage(); 35 - this._fetch = options.fetch ?? globalThis.fetch; 34 + this._fetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch; 36 35 } 37 36 38 37 get isConfidential(): boolean { ··· 41 40 42 41 async provisionDpopKey(): Promise<{ 43 42 provisionId: string; 44 - dpopKey: JsonWebKey; 43 + dpopKey: Key; 44 + rawJwk: JsonWebKey; 45 45 pkceVerifier?: string; 46 46 }> { 47 47 const headers: Record<string, string> = { ··· 56 56 let pkceVerifier: string | undefined; 57 57 58 58 if (!this.isConfidential) { 59 - pkceVerifier = await this.crypto.generatePkceVerifier(); 60 - const challenge = await this.crypto.computePkceChallenge(pkceVerifier); 59 + pkceVerifier = generatePkceVerifier(); 60 + const challenge = await computePkceChallenge(pkceVerifier); 61 61 body.pkce_challenge = challenge; 62 62 } 63 63 ··· 77 77 } 78 78 79 79 const data: ProvisionKeyResponse = await resp.json(); 80 + const dpopKey = await importJwk(data.dpop_key); 80 81 return { 81 82 provisionId: data.provision_id, 82 - dpopKey: data.dpop_key, 83 + dpopKey, 84 + rawJwk: data.dpop_key, 83 85 pkceVerifier, 84 86 }; 85 87 } ··· 125 127 } 126 128 127 129 const data: RegisterSessionResponse = await resp.json(); 130 + const dpopKey = await importJwk(params.dpopKey); 128 131 129 132 const storedSession: StoredSession = { 130 133 did: data.did, ··· 141 144 142 145 return new HappyViewSession({ 143 146 did: data.did, 144 - dpopKey: params.dpopKey, 147 + dpopKey, 145 148 accessToken: params.accessToken, 146 149 clientKey: this.clientKey, 147 150 instanceUrl: this.instanceUrl, 148 - crypto: this.crypto, 151 + fetch: this._fetch, 149 152 }); 150 153 } 151 154 ··· 187 190 if (!stored) return null; 188 191 189 192 const data: StoredSession = JSON.parse(stored); 193 + const dpopKey = await importJwk(data.dpopKey); 190 194 return new HappyViewSession({ 191 195 did: data.did, 192 - dpopKey: data.dpopKey, 196 + dpopKey, 193 197 accessToken: data.accessToken, 194 198 clientKey: data.clientKey, 195 199 instanceUrl: data.instanceUrl, 196 - crypto: this.crypto, 200 + fetch: this._fetch, 197 201 }); 198 202 } 199 203 ··· 203 207 return this.restoreSession(did); 204 208 } 205 209 } 210 + 211 + // PKCE helpers — inline since they're just a few lines of Web Crypto 212 + function generatePkceVerifier(): string { 213 + const bytes = crypto.getRandomValues(new Uint8Array(32)); 214 + let binary = ""; 215 + for (let i = 0; i < bytes.length; i++) { 216 + binary += String.fromCharCode(bytes[i]); 217 + } 218 + return btoa(binary) 219 + .replace(/\+/g, "-") 220 + .replace(/\//g, "_") 221 + .replace(/=+$/, ""); 222 + } 223 + 224 + async function computePkceChallenge(verifier: string): Promise<string> { 225 + const hash = await crypto.subtle.digest( 226 + "SHA-256", 227 + new TextEncoder().encode(verifier), 228 + ); 229 + const bytes = new Uint8Array(hash); 230 + let binary = ""; 231 + for (let i = 0; i < bytes.length; i++) { 232 + binary += String.fromCharCode(bytes[i]); 233 + } 234 + return btoa(binary) 235 + .replace(/\+/g, "-") 236 + .replace(/\//g, "_") 237 + .replace(/=+$/, ""); 238 + }
-75
packages/oauth-client/src/dpop-proof.ts
··· 1 - import type { CryptoAdapter } from "./types"; 2 - 3 - export function base64urlEncode(data: Uint8Array): string { 4 - let binary = ""; 5 - for (let i = 0; i < data.length; i++) { 6 - binary += String.fromCharCode(data[i]); 7 - } 8 - return btoa(binary) 9 - .replace(/\+/g, "-") 10 - .replace(/\//g, "_") 11 - .replace(/=+$/, ""); 12 - } 13 - 14 - export interface DpopProofParams { 15 - privateKey: JsonWebKey; 16 - method: string; 17 - url: string; 18 - accessToken?: string; 19 - nonce?: string; 20 - } 21 - 22 - export async function generateDpopProof( 23 - crypto: CryptoAdapter, 24 - params: DpopProofParams, 25 - ): Promise<string> { 26 - const { privateKey, method, url, accessToken, nonce } = params; 27 - 28 - const publicJwk = { 29 - kty: privateKey.kty, 30 - crv: privateKey.crv, 31 - x: privateKey.x, 32 - y: privateKey.y, 33 - }; 34 - 35 - const header = { 36 - alg: "ES256", 37 - typ: "dpop+jwt", 38 - jwk: publicJwk, 39 - }; 40 - 41 - // Generate jti 42 - const jtiBytes = crypto.getRandomValues(16); 43 - const jti = Array.from(jtiBytes, (b) => b.toString(16).padStart(2, "0")).join(""); 44 - 45 - const payload: Record<string, unknown> = { 46 - htm: method, 47 - htu: url, 48 - iat: Math.floor(Date.now() / 1000), 49 - jti, 50 - }; 51 - 52 - // Compute ath: base64url(SHA-256(access_token)) — only when token is present 53 - if (accessToken) { 54 - const tokenBytes = new TextEncoder().encode(accessToken); 55 - const hashBuf = await crypto.sha256(tokenBytes); 56 - payload.ath = base64urlEncode(hashBuf); 57 - } 58 - 59 - if (nonce !== undefined) { 60 - payload.nonce = nonce; 61 - } 62 - 63 - const headerB64 = base64urlEncode( 64 - new TextEncoder().encode(JSON.stringify(header)), 65 - ); 66 - const payloadB64 = base64urlEncode( 67 - new TextEncoder().encode(JSON.stringify(payload)), 68 - ); 69 - 70 - const message = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 71 - const signature = await crypto.signEs256(privateKey, message); 72 - const sigB64 = base64urlEncode(signature); 73 - 74 - return `${headerB64}.${payloadB64}.${sigB64}`; 75 - }
+27
packages/oauth-client/src/import-jwk.ts
··· 1 + import { WebcryptoKey } from "@atproto/jwk-webcrypto"; 2 + 3 + /** 4 + * Import a raw ES256 JWK (as returned by HappyView's /oauth/dpop-keys) 5 + * into a WebcryptoKey that can sign JWTs. 6 + */ 7 + export async function importJwk(jwk: JsonWebKey): Promise<WebcryptoKey> { 8 + const privateKey = await crypto.subtle.importKey( 9 + "jwk", 10 + jwk, 11 + { name: "ECDSA", namedCurve: "P-256" }, 12 + true, 13 + ["sign"], 14 + ); 15 + 16 + // Derive public key by stripping the private component 17 + const { d: _, ...publicJwkFields } = jwk; 18 + const publicKey = await crypto.subtle.importKey( 19 + "jwk", 20 + publicJwkFields, 21 + { name: "ECDSA", namedCurve: "P-256" }, 22 + true, 23 + ["verify"], 24 + ); 25 + 26 + return WebcryptoKey.fromKeypair({ privateKey, publicKey }); 27 + }
+1 -3
packages/oauth-client/src/index.ts
··· 1 1 export { HappyViewOAuthClient } from "./client"; 2 - export { base64urlEncode, generateDpopProof } from "./dpop-proof"; 3 - export type { DpopProofParams } from "./dpop-proof"; 2 + export { importJwk } from "./import-jwk"; 4 3 export { 5 4 ApiError, 6 5 AuthenticationError, ··· 13 12 export type { HappyViewSessionOptions } from "./session"; 14 13 export { MemoryStorage } from "./storage"; 15 14 export type { 16 - CryptoAdapter, 17 15 DpopProvision, 18 16 HappyViewOAuthClientOptions, 19 17 ProvisionKeyResponse,
+74 -25
packages/oauth-client/src/session.ts
··· 1 - import { generateDpopProof } from "./dpop-proof"; 2 - import type { CryptoAdapter } from "./types"; 1 + import type { Key } from "@atproto/jwk"; 3 2 4 3 export interface HappyViewSessionOptions { 5 4 did: string; 6 - dpopKey: JsonWebKey; 5 + dpopKey: Key; 7 6 accessToken: string; 8 7 clientKey: string; 9 8 instanceUrl: string; 10 - crypto: CryptoAdapter; 11 9 fetch?: typeof globalThis.fetch; 12 10 } 13 11 12 + function randomHex(byteLength: number): string { 13 + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); 14 + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); 15 + } 16 + 17 + function base64url(buffer: ArrayBuffer): string { 18 + const bytes = new Uint8Array(buffer); 19 + let binary = ""; 20 + for (let i = 0; i < bytes.length; i++) { 21 + binary += String.fromCharCode(bytes[i]); 22 + } 23 + return btoa(binary) 24 + .replace(/\+/g, "-") 25 + .replace(/\//g, "_") 26 + .replace(/=+$/, ""); 27 + } 28 + 14 29 export class HappyViewSession { 15 30 readonly did: string; 16 31 17 - private readonly dpopKey: JsonWebKey; 32 + private readonly dpopKey: Key; 18 33 private readonly accessToken: string; 19 34 private readonly clientKey: string; 20 35 private readonly instanceUrl: string; 21 - private readonly crypto: CryptoAdapter; 22 36 private readonly _fetch: typeof globalThis.fetch; 23 37 private dpopNonce: string | undefined; 24 38 ··· 28 42 this.accessToken = options.accessToken; 29 43 this.clientKey = options.clientKey; 30 44 this.instanceUrl = options.instanceUrl.replace(/\/+$/, ""); 31 - this.crypto = options.crypto; 32 - this._fetch = options.fetch ?? globalThis.fetch; 45 + this._fetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch; 33 46 } 34 47 35 48 async fetchHandler(url: string, init: RequestInit): Promise<Response> { 36 - const fullUrl = /^https?:\/\//i.test(url) ? url : `${this.instanceUrl}${url}`; 49 + const fullUrl = /^https?:\/\//i.test(url) 50 + ? url 51 + : `${this.instanceUrl}${url}`; 37 52 const method = (init.method ?? "GET").toUpperCase(); 53 + const htu = fullUrl.split("?")[0]; 38 54 39 - const proof = await generateDpopProof(this.crypto, { 40 - privateKey: this.dpopKey, 41 - method, 42 - url: fullUrl, 43 - accessToken: this.accessToken, 44 - nonce: this.dpopNonce, 45 - }); 55 + const sendWithProof = async (): Promise<Response> => { 56 + const tokenHash = await crypto.subtle.digest( 57 + "SHA-256", 58 + new TextEncoder().encode(this.accessToken), 59 + ); 60 + const ath = base64url(tokenHash); 61 + 62 + const proof = await this.dpopKey.createJwt( 63 + { 64 + alg: "ES256", 65 + typ: "dpop+jwt", 66 + jwk: this.dpopKey.publicJwk!, 67 + }, 68 + { 69 + htm: method, 70 + htu, 71 + iat: Math.floor(Date.now() / 1000), 72 + jti: randomHex(16), 73 + ath, 74 + nonce: this.dpopNonce, 75 + }, 76 + ); 77 + 78 + const headers = new Headers(init.headers); 79 + headers.set("authorization", `DPoP ${this.accessToken}`); 80 + headers.set("dpop", proof); 81 + headers.set("x-client-key", this.clientKey); 46 82 47 - const headers = new Headers(init.headers); 48 - headers.set("authorization", `DPoP ${this.accessToken}`); 49 - headers.set("dpop", proof); 50 - headers.set("x-client-key", this.clientKey); 83 + return this._fetch(fullUrl, { ...init, headers }); 84 + }; 51 85 52 - const response = await this._fetch(fullUrl, { 53 - ...init, 54 - headers, 55 - }); 86 + let response: Response; 87 + try { 88 + response = await sendWithProof(); 89 + } catch (e) { 90 + const info = `_fetch type: ${typeof this._fetch}, toString: ${String(this._fetch).slice(0, 120)}, url: ${fullUrl}`; 91 + throw new Error(`fetchHandler failed: ${(e as Error).message}\n\nDEBUG: ${info}`); 92 + } 56 93 94 + // Retry once if the server requires a DPoP nonce we didn't have 57 95 const nonce = response.headers.get("dpop-nonce"); 58 - if (nonce) { 96 + if (nonce && nonce !== this.dpopNonce && (response.status === 401 || response.status === 400)) { 97 + this.dpopNonce = nonce; 98 + try { 99 + response = await sendWithProof(); 100 + } catch (e) { 101 + throw new Error(`fetchHandler retry failed: ${(e as Error).message}`); 102 + } 103 + const retryNonce = response.headers.get("dpop-nonce"); 104 + if (retryNonce) { 105 + this.dpopNonce = retryNonce; 106 + } 107 + } else if (nonce) { 59 108 this.dpopNonce = nonce; 60 109 } 61 110
-9
packages/oauth-client/src/types.ts
··· 1 - export interface CryptoAdapter { 2 - generatePkceVerifier(): Promise<string>; 3 - computePkceChallenge(verifier: string): Promise<string>; 4 - signEs256(privateKey: JsonWebKey, payload: Uint8Array): Promise<Uint8Array>; 5 - sha256(data: Uint8Array): Promise<Uint8Array>; 6 - getRandomValues(length: number): Uint8Array; 7 - } 8 - 9 1 export interface StorageAdapter { 10 2 get(key: string): Promise<string | null>; 11 3 set(key: string, value: string): Promise<void>; ··· 16 8 instanceUrl: string; 17 9 clientKey: string; 18 10 clientSecret?: string; 19 - crypto: CryptoAdapter; 20 11 storage?: StorageAdapter; 21 12 } 22 13