a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

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

feat(oauth-node-client): initial commit

Mary 01e5c58d 12e79545

+7445 -6
+4
packages/oauth/node-client-example/.env
··· 1 + PUBLIC_URL= 2 + PORT=3000 3 + PRIVATE_KEY_JWK= 4 + COOKIE_SECRET=
+1
packages/oauth/node-client-example/.gitignore
··· 1 + .env.local
+83
packages/oauth/node-client-example/README.md
··· 1 + # @atcute/oauth-node-client example 2 + 3 + this example demonstrates OAuth authentication for AT Protocol using `@atcute/oauth-node-client`. 4 + 5 + ## requirements 6 + 7 + confidential OAuth clients must be accessible via **https** since the authorization server (e.g. 8 + Bluesky's PDS) needs to fetch the client's JWKS from the client_id URL. 9 + 10 + for local development, use a tunneling service like [ngrok](https://ngrok.com/), 11 + [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/), 12 + or similar. 13 + 14 + ## setup 15 + 16 + install dependencies: 17 + 18 + ```sh 19 + bun install 20 + ``` 21 + 22 + create `.env.local` and generate a fresh private key: 23 + 24 + ```sh 25 + bun run setup:env 26 + ``` 27 + 28 + then edit `.env.local` and set `PUBLIC_URL` to your public https url. 29 + 30 + ## running with ngrok 31 + 32 + 1. start ngrok tunnel: 33 + 34 + ```sh 35 + ngrok http 3000 36 + ``` 37 + 38 + 2. copy the https URL (e.g. `https://abc123.ngrok.io`) 39 + 40 + 3. set `PUBLIC_URL` in `.env.local`, or start the server with the public URL: 41 + 42 + ```sh 43 + PUBLIC_URL=https://abc123.ngrok.io bun run dev 44 + ``` 45 + 46 + 4. open the ngrok URL in your browser 47 + 48 + ## environment variables 49 + 50 + - `PUBLIC_URL` (required) - the https URL where this app is accessible 51 + - `PORT` (optional) - local listen port (default: `3000`) 52 + - `PRIVATE_KEY_JWK` (required) - JSON Web Key used for client authentication (`private_key_jwt`) 53 + - `COOKIE_SECRET` (optional) - secret for signed cookies. `setup:env` generates one; if unset, a secret is derived from `PRIVATE_KEY_JWK` 54 + 55 + ## generating a private key 56 + 57 + the `setup:env` script generates a fresh key and writes it into `.env.local`. to rotate it, run: 58 + 59 + ```sh 60 + bun run setup:env 61 + ``` 62 + 63 + for production, generate and store a persistent key: 64 + 65 + ```js 66 + import { generatePrivateKey, exportJwkKey } from '@atcute/oauth-node-client'; 67 + 68 + const key = await generatePrivateKey('main', 'ES256'); 69 + const jwk = await exportJwkKey(key); 70 + console.log(JSON.stringify(jwk)); 71 + ``` 72 + 73 + then set `PRIVATE_KEY_JWK` to the output. 74 + 75 + ## routes 76 + 77 + - `/` - home page with login form 78 + - `/oauth/login` - starts OAuth authorization flow 79 + - `/oauth/callback` - OAuth callback handler 80 + - `/protected` - example protected resource (fetches session info) 81 + - `/logout` - revokes tokens and clears session 82 + - `/client-metadata.json` - serves client metadata for discovery 83 + - `/jwks.json` - serves client jwks (public keys) for discovery
+22
packages/oauth/node-client-example/package.json
··· 1 + { 2 + "type": "module", 3 + "name": "node-client-example", 4 + "private": true, 5 + "scripts": { 6 + "dev": "bun run --hot src/index.ts", 7 + "setup:env": "bun run scripts/setup-env.ts" 8 + }, 9 + "dependencies": { 10 + "@atcute/atproto": "workspace:*", 11 + "@atcute/client": "workspace:*", 12 + "@atcute/identity-resolver": "workspace:*", 13 + "@atcute/identity-resolver-node": "workspace:^", 14 + "@atcute/lexicons": "workspace:*", 15 + "@atcute/oauth-node-client": "workspace:*", 16 + "hono": "^4.11.0", 17 + "nanoid": "^5.1.6" 18 + }, 19 + "devDependencies": { 20 + "@types/bun": "latest" 21 + } 22 + }
+54
packages/oauth/node-client-example/scripts/setup-env.ts
··· 1 + import { existsSync } from 'node:fs'; 2 + import { copyFile, readFile, writeFile } from 'node:fs/promises'; 3 + import { resolve } from 'node:path'; 4 + 5 + import { exportJwkKey, generatePrivateKey } from '@atcute/oauth-node-client'; 6 + import { nanoid } from 'nanoid'; 7 + 8 + const ensureEnvLocal = async (): Promise<string> => { 9 + const envPath = resolve(process.cwd(), '.env'); 10 + const envLocalPath = resolve(process.cwd(), '.env.local'); 11 + 12 + if (!existsSync(envLocalPath)) { 13 + await copyFile(envPath, envLocalPath); 14 + } 15 + 16 + return envLocalPath; 17 + }; 18 + 19 + const upsertEnvVar = (input: string, key: string, value: string): string => { 20 + const line = `${key}=${value}`; 21 + const re = new RegExp(`^${key}=.*$`, 'm'); 22 + 23 + if (re.test(input)) { 24 + const match = input.match(re); 25 + const current = match ? match[0].slice(key.length + 1) : ''; 26 + const trimmed = current.trim(); 27 + 28 + if (trimmed === '' || trimmed === `''` || trimmed === `""`) { 29 + return input.replace(re, line); 30 + } 31 + 32 + return input; 33 + } 34 + 35 + const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n'; 36 + return `${input}${suffix}${line}\n`; 37 + }; 38 + 39 + const envLocalPath = await ensureEnvLocal(); 40 + const envLocal = await readFile(envLocalPath, 'utf8'); 41 + 42 + const privateKey = await generatePrivateKey('main', 'ES256'); 43 + const jwk = await exportJwkKey(privateKey); 44 + const jwkJson = JSON.stringify(jwk); 45 + 46 + const cookieSecret = nanoid(32); 47 + 48 + let updated = envLocal; 49 + updated = upsertEnvVar(updated, 'PRIVATE_KEY_JWK', `'${jwkJson}'`); 50 + updated = upsertEnvVar(updated, 'COOKIE_SECRET', cookieSecret); 51 + 52 + await writeFile(envLocalPath, updated); 53 + 54 + console.log(`updated ${envLocalPath}`);
+354
packages/oauth/node-client-example/src/index.ts
··· 1 + import { ComAtprotoServerGetSession } from '@atcute/atproto'; 2 + import { Client } from '@atcute/client'; 3 + import { 4 + CompositeDidDocumentResolver, 5 + CompositeHandleResolver, 6 + LocalActorResolver, 7 + PlcDidDocumentResolver, 8 + WebDidDocumentResolver, 9 + WellKnownHandleResolver, 10 + } from '@atcute/identity-resolver'; 11 + import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node'; 12 + import { isActorIdentifier, isDid, type Did } from '@atcute/lexicons/syntax'; 13 + import { 14 + MemoryStore, 15 + OAuthCallbackError, 16 + OAuthClient, 17 + importJwkKey, 18 + type AuthorizeTarget, 19 + type StoredState, 20 + } from '@atcute/oauth-node-client'; 21 + 22 + import { Hono, type Context } from 'hono'; 23 + import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie'; 24 + 25 + const SESSION_COOKIE = 'atcute_oauth_did'; 26 + 27 + const ONE_MINUTE_MS = 60_000; 28 + const TEN_MINUTES_MS = 10 * ONE_MINUTE_MS; 29 + 30 + const isProbablyUrl = (input: string): boolean => { 31 + try { 32 + const url = new URL(input); 33 + return url.protocol === 'https:' || url.protocol === 'http:'; 34 + } catch { 35 + return false; 36 + } 37 + }; 38 + 39 + const escapeHtml = (input: string): string => { 40 + return input.replace(/[&<>"']/g, (ch) => { 41 + switch (ch) { 42 + case '&': 43 + return '&amp;'; 44 + case '<': 45 + return '&lt;'; 46 + case '>': 47 + return '&gt;'; 48 + case '"': 49 + return '&quot;'; 50 + case "'": 51 + return '&#39;'; 52 + default: 53 + return ch; 54 + } 55 + }); 56 + }; 57 + 58 + const renderPage = (title: string, body: string): string => { 59 + return `<!doctype html> 60 + <html lang="en"> 61 + <head> 62 + <meta charset="utf-8" /> 63 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 64 + <title>${escapeHtml(title)}</title> 65 + <style> 66 + :root { color-scheme: light dark; } 67 + body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; margin: 2rem; line-height: 1.4; } 68 + main { max-width: 58rem; } 69 + input { padding: 0.6rem 0.8rem; width: min(34rem, 100%); } 70 + button { padding: 0.6rem 0.8rem; } 71 + code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.95em; } 72 + pre { padding: 1rem; border: 1px solid color-mix(in oklab, currentColor 22%, transparent); overflow-x: auto; } 73 + hr { border: 0; border-top: 1px solid color-mix(in oklab, currentColor 22%, transparent); margin: 1.5rem 0; } 74 + .row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; } 75 + .muted { opacity: 0.75; } 76 + </style> 77 + </head> 78 + <body> 79 + <main> 80 + <h1>${escapeHtml(title)}</h1> 81 + ${body} 82 + </main> 83 + </body> 84 + </html>`; 85 + }; 86 + 87 + const publicUrlRaw = process.env.PUBLIC_URL; 88 + if (!publicUrlRaw) { 89 + throw new Error(`missing env var: PUBLIC_URL`); 90 + } 91 + 92 + const publicUrl = new URL(publicUrlRaw); 93 + 94 + const privateKeyJwk = process.env.PRIVATE_KEY_JWK; 95 + if (!privateKeyJwk) { 96 + throw new Error(`missing env var: PRIVATE_KEY_JWK`); 97 + } 98 + 99 + const cookieSecret = process.env.COOKIE_SECRET; 100 + if (!cookieSecret) { 101 + throw new Error(`missing env var: COOKIE_SECRET`); 102 + } 103 + 104 + const oauth = new OAuthClient({ 105 + metadata: { 106 + client_id: new URL('/oauth-client-metadata.json', publicUrl).href, 107 + client_name: 'atcute oauth node client example', 108 + redirect_uris: [new URL('/oauth/callback', publicUrl).href], 109 + scope: 'atproto', 110 + jwks_uri: new URL('/jwks.json', publicUrl).href, 111 + }, 112 + 113 + keyset: await Promise.all([importJwkKey(privateKeyJwk)]), 114 + 115 + actorResolver: new LocalActorResolver({ 116 + handleResolver: new CompositeHandleResolver({ 117 + methods: { 118 + dns: new NodeDnsHandleResolver(), 119 + http: new WellKnownHandleResolver(), 120 + }, 121 + }), 122 + didDocumentResolver: new CompositeDidDocumentResolver({ 123 + methods: { 124 + plc: new PlcDidDocumentResolver(), 125 + web: new WebDidDocumentResolver(), 126 + }, 127 + }), 128 + }), 129 + 130 + stores: { 131 + sessions: new MemoryStore({ 132 + maxSize: 10_000, 133 + }), 134 + states: new MemoryStore<string, StoredState>({ 135 + maxSize: 10_000, 136 + ttl: TEN_MINUTES_MS, 137 + ttlAutopurge: true, 138 + }), 139 + }, 140 + }); 141 + 142 + const getSessionDid = async (c: Context): Promise<Did | null> => { 143 + const did = await getSignedCookie(c, cookieSecret, SESSION_COOKIE); 144 + 145 + if (isDid(did)) { 146 + return did; 147 + } 148 + 149 + return null; 150 + }; 151 + 152 + const setSessionDid = async (c: Context, did: Did, secure: boolean): Promise<void> => { 153 + await setSignedCookie(c, SESSION_COOKIE, did, cookieSecret, { 154 + httpOnly: true, 155 + secure, 156 + sameSite: 'Lax', 157 + path: '/', 158 + }); 159 + }; 160 + 161 + const clearSessionDid = (c: Context, secure: boolean): void => { 162 + deleteCookie(c, SESSION_COOKIE, { 163 + httpOnly: true, 164 + secure, 165 + sameSite: 'Lax', 166 + path: '/', 167 + }); 168 + }; 169 + 170 + const renderError = (title: string, err: unknown): string => { 171 + let message = 'unknown error'; 172 + if (err instanceof Error) { 173 + message = err.message; 174 + } 175 + 176 + let extra = ''; 177 + if (err instanceof OAuthCallbackError) { 178 + extra = `\nerror: ${err.error}\nstate: ${err.state ?? '(missing)'}`; 179 + } 180 + 181 + return renderPage( 182 + title, 183 + `<p class="muted">something went wrong.</p> 184 + <pre>${escapeHtml(`${message}${extra}`)}</pre> 185 + <p><a href="/">back home</a></p>`, 186 + ); 187 + }; 188 + 189 + /** example hono app showcasing `@atcute/oauth-node-client`. */ 190 + const app = new Hono(); 191 + 192 + app.get('/', async (c) => { 193 + const secure = publicUrl.protocol === 'https:'; 194 + const did = await getSessionDid(c); 195 + 196 + let sessionInfoHtml = `<p class="muted">not signed in.</p>`; 197 + if (did) { 198 + try { 199 + const session = await oauth.restore(did, { refresh: 'auto' }); 200 + const tokenInfo = await session.getTokenInfo('auto'); 201 + 202 + sessionInfoHtml = `<p>signed in as <code>${escapeHtml(session.did)}</code></p> 203 + <pre>${escapeHtml(JSON.stringify(tokenInfo, null, 2))}</pre> 204 + <div class="row"> 205 + <a href="/protected">open protected page</a> 206 + <a href="/logout">logout</a> 207 + </div>`; 208 + } catch { 209 + clearSessionDid(c, secure); 210 + } 211 + } 212 + 213 + return c.html( 214 + renderPage( 215 + 'atcute oauth node client example', 216 + `${sessionInfoHtml} 217 + <hr /> 218 + <form method="post" action="/oauth/login"> 219 + <div class="row"> 220 + <input name="identifier" placeholder="handle (alice.bsky.social) or did (did:plc:...)" required /> 221 + <button type="submit">login</button> 222 + </div> 223 + </form> 224 + <hr /> 225 + <p class="muted">debug endpoints: <a href="/oauth-client-metadata.json">oauth-client-metadata.json</a>, <a href="/jwks.json">jwks.json</a></p>`, 226 + ), 227 + ); 228 + }); 229 + 230 + app.post('/oauth/login', async (c) => { 231 + try { 232 + const body = await c.req.parseBody(); 233 + 234 + const identifier = (typeof body.identifier === 'string' ? body.identifier : '').trim(); 235 + if (!identifier) { 236 + return c.html( 237 + renderPage('login', `<p class="muted">missing identifier.</p><p><a href="/">back</a></p>`), 238 + 400, 239 + ); 240 + } 241 + 242 + let target: AuthorizeTarget; 243 + if (isProbablyUrl(identifier)) { 244 + target = { type: 'pds', serviceUrl: identifier }; 245 + } else if (isActorIdentifier(identifier)) { 246 + target = { type: 'account', identifier: identifier }; 247 + } else { 248 + return c.html( 249 + renderPage( 250 + 'login', 251 + `<p class="muted">invalid identifier. expected a handle (e.g. <code>alice.bsky.social</code>) or a did (e.g. <code>did:plc:...</code>).</p> 252 + <p><a href="/">back</a></p>`, 253 + ), 254 + 400, 255 + ); 256 + } 257 + 258 + const { url } = await oauth.authorize({ 259 + target, 260 + scope: 'atproto', 261 + state: { startedAt: Date.now() }, 262 + }); 263 + 264 + return c.redirect(url.href, 302); 265 + } catch (err) { 266 + return c.html(renderError('login', err), 500); 267 + } 268 + }); 269 + 270 + app.get('/oauth/callback', async (c) => { 271 + try { 272 + const params = new URL(c.req.url).searchParams; 273 + 274 + const { session } = await oauth.callback(params); 275 + await setSessionDid(c, session.did, publicUrl.protocol === 'https:'); 276 + 277 + return c.redirect('/protected', 302); 278 + } catch (err) { 279 + return c.html(renderError('callback', err), 500); 280 + } 281 + }); 282 + 283 + app.get('/protected', async (c) => { 284 + try { 285 + const secure = publicUrl.protocol === 'https:'; 286 + 287 + const did = await getSessionDid(c); 288 + if (!did) { 289 + return c.redirect('/', 302); 290 + } 291 + 292 + let session; 293 + try { 294 + session = await oauth.restore(did, { refresh: 'auto' }); 295 + } catch { 296 + clearSessionDid(c, secure); 297 + return c.redirect('/', 302); 298 + } 299 + 300 + const rpc = new Client({ handler: session }); 301 + const tokenInfo = await session.getTokenInfo('auto'); 302 + 303 + const sessionResponse = await rpc.call(ComAtprotoServerGetSession, {}); 304 + const sessionBody = sessionResponse.ok ? sessionResponse.data : { status: sessionResponse.status }; 305 + 306 + return c.html( 307 + renderPage( 308 + 'protected', 309 + `<p>this page calls <code>com.atproto.server.getSession</code> using the oauth session.</p> 310 + <div class="row"> 311 + <a href="/">home</a> 312 + <a href="/logout">logout</a> 313 + </div> 314 + <hr /> 315 + <h2>token</h2> 316 + <pre>${escapeHtml(JSON.stringify(tokenInfo, null, 2))}</pre> 317 + <h2>pds session</h2> 318 + <pre>${escapeHtml(JSON.stringify(sessionBody, null, 2))}</pre>`, 319 + ), 320 + ); 321 + } catch (err) { 322 + return c.html(renderError('protected', err), 500); 323 + } 324 + }); 325 + 326 + app.get('/logout', async (c) => { 327 + try { 328 + const secure = publicUrl.protocol === 'https:'; 329 + 330 + const did = await getSessionDid(c); 331 + if (did) { 332 + try { 333 + await oauth.revoke(did); 334 + } catch { 335 + // ignore errors, we still clear local session 336 + } 337 + } 338 + 339 + clearSessionDid(c, secure); 340 + return c.redirect('/', 302); 341 + } catch (err) { 342 + return c.html(renderError('logout', err), 500); 343 + } 344 + }); 345 + 346 + app.get('/oauth-client-metadata.json', async (c) => { 347 + return c.json(oauth.metadata); 348 + }); 349 + 350 + app.get('/jwks.json', async (c) => { 351 + return c.json(oauth.jwks); 352 + }); 353 + 354 + export default app;
+24
packages/oauth/node-client-example/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "types": ["bun"], 4 + "noEmit": true, 5 + "esModuleInterop": true, 6 + "skipLibCheck": true, 7 + "target": "ESNext", 8 + "allowJs": true, 9 + "resolveJsonModule": true, 10 + "moduleDetection": "force", 11 + "isolatedModules": true, 12 + "verbatimModuleSyntax": true, 13 + "strict": true, 14 + "noImplicitOverride": true, 15 + "noUnusedLocals": true, 16 + "noUnusedParameters": true, 17 + "noFallthroughCasesInSwitch": true, 18 + "module": "NodeNext", 19 + "sourceMap": true, 20 + "declaration": true, 21 + "declarationMap": true, 22 + }, 23 + "include": ["src", "scripts"], 24 + }
+269
packages/oauth/node-client/README.md
··· 1 + # @atcute/oauth-node-client 2 + 3 + atproto OAuth client for Node.js (and other server runtimes) 4 + 5 + ```sh 6 + npm install @atcute/oauth-node-client 7 + ``` 8 + 9 + ## key management 10 + 11 + this package is for **confidential clients**. it authenticates to the token endpoint using 12 + `private_key_jwt`, which means you need a persistent private key in production (not an ephemeral key 13 + generated at startup). 14 + 15 + one convenient pattern is to keep a committed `.env` with empty placeholders, and generate a 16 + developer-specific `.env.local` that is never checked in. the script below only fills empty values 17 + and refuses to overwrite non-empty ones. 18 + 19 + 1. create `.env` with an empty value: 20 + 21 + ```dotenv 22 + PRIVATE_KEY_JWK= 23 + ``` 24 + 25 + 2. add `scripts/setup-env.mjs`: 26 + 27 + ```js 28 + import { existsSync } from 'node:fs'; 29 + import { copyFile, readFile, writeFile } from 'node:fs/promises'; 30 + import { resolve } from 'node:path'; 31 + 32 + import { exportJwkKey, generatePrivateKey, importJwkKey } from '@atcute/oauth-node-client'; 33 + 34 + const ensureEnvLocal = async () => { 35 + const envPath = resolve(process.cwd(), '.env'); 36 + const envLocalPath = resolve(process.cwd(), '.env.local'); 37 + 38 + if (!existsSync(envLocalPath)) { 39 + await copyFile(envPath, envLocalPath); 40 + } 41 + 42 + return envLocalPath; 43 + }; 44 + 45 + const upsertEnvVar = (input, key, value) => { 46 + const line = `${key}=${value}`; 47 + const re = new RegExp(`^${key}=.*$`, 'm'); 48 + 49 + if (re.test(input)) { 50 + const match = input.match(re); 51 + const current = match ? match[0].slice(key.length + 1) : ''; 52 + const trimmed = current.trim(); 53 + 54 + if (trimmed === '' || trimmed === `''` || trimmed === `""`) { 55 + return input.replace(re, line); 56 + } 57 + 58 + return input; 59 + } 60 + 61 + const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n'; 62 + return `${input}${suffix}${line}\n`; 63 + }; 64 + 65 + const envLocalPath = await ensureEnvLocal(); 66 + const envLocal = await readFile(envLocalPath, 'utf8'); 67 + 68 + const privateKey = await generatePrivateKey('main', 'ES256'); 69 + const jwk = await exportJwkKey(privateKey); 70 + 71 + // sanity-check that the key parses before writing 72 + await importJwkKey(jwk); 73 + 74 + const jwkJson = JSON.stringify(jwk); 75 + const updated = upsertEnvVar(envLocal, 'PRIVATE_KEY_JWK', `'${jwkJson}'`); 76 + 77 + if (updated !== envLocal) { 78 + await writeFile(envLocalPath, updated); 79 + console.log(`updated ${envLocalPath}`); 80 + } else { 81 + console.log(`no changes to ${envLocalPath}`); 82 + } 83 + ``` 84 + 85 + 3. run it: 86 + 87 + ```sh 88 + node scripts/setup-env.mjs 89 + ``` 90 + 91 + 4. create a keyset at runtime: 92 + 93 + ```ts 94 + import { importJwkKey } from '@atcute/oauth-node-client'; 95 + 96 + const keyset = await Promise.all([importJwkKey(process.env.PRIVATE_KEY_JWK!)]); 97 + ``` 98 + 99 + ## stores 100 + 101 + you provide storage for sessions and authorization state: 102 + 103 + - sessions are keyed by the user's DID and should be long-lived 104 + - states are keyed by the OAuth `state` value and should be short-lived (~10 minutes) 105 + 106 + for development or single-instance deployments, `MemoryStore` is fine, but for real deployments 107 + you'll likely want a shared store (e.g. Redis) and a distributed lock. 108 + 109 + the minimal shape is the exported `Store` interface: 110 + 111 + ```ts 112 + const stores: OAuthClientStores = { 113 + sessions: { 114 + async get(did, options) { 115 + // ... 116 + }, 117 + async set(did, session) { 118 + // ... 119 + }, 120 + async delete(did) { 121 + // ... 122 + }, 123 + async clear() {}, 124 + }, 125 + states: { 126 + async get(stateId, options) { 127 + // ... 128 + }, 129 + async set(stateId, state) { 130 + // ... 131 + }, 132 + async delete(stateId) { 133 + // ... 134 + }, 135 + async clear() {}, 136 + }, 137 + }; 138 + ``` 139 + 140 + your `states` store should apply TTL expiration (about 10 minutes) and your `sessions` store should 141 + be durable across restarts. 142 + 143 + ## usage 144 + 145 + examples below use Hono (web `Request`/`Response` style), but any web framework works. 146 + 147 + ```ts 148 + import { Hono } from 'hono'; 149 + 150 + const app = new Hono(); 151 + ``` 152 + 153 + ### create an OAuth client 154 + 155 + ```ts 156 + import type { ConfidentialClientMetadata } from '@atcute/oauth-node-client'; 157 + import { OAuthClient, importJwkKey } from '@atcute/oauth-node-client'; 158 + import { 159 + CompositeDidDocumentResolver, 160 + CompositeHandleResolver, 161 + LocalActorResolver, 162 + PlcDidDocumentResolver, 163 + WebDidDocumentResolver, 164 + WellKnownHandleResolver, 165 + } from '@atcute/identity-resolver'; 166 + import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node'; 167 + 168 + const keyset = await Promise.all([importJwkKey(process.env.PRIVATE_KEY_JWK!)]); 169 + 170 + const oauth = new OAuthClient({ 171 + metadata: { 172 + // this must be the URL where you serve `oauth.metadata` (below). 173 + client_id: 'https://example.com/oauth-client-metadata.json', 174 + redirect_uris: ['https://example.com/oauth/callback'], 175 + scope: 'atproto transition:generic', 176 + // optional: if set, this must be the URL where you serve `oauth.jwks` (below). 177 + // must be same-origin as client_id. if omitted, `oauth.metadata` will inline jwks instead. 178 + jwks_uri: 'https://example.com/jwks.json', 179 + }, 180 + 181 + keyset, 182 + 183 + stores: { 184 + // ... 185 + }, 186 + 187 + actorResolver: new LocalActorResolver({ 188 + handleResolver: new CompositeHandleResolver({ 189 + methods: { 190 + dns: new NodeDnsHandleResolver(), 191 + http: new WellKnownHandleResolver(), 192 + }, 193 + }), 194 + didDocumentResolver: new CompositeDidDocumentResolver({ 195 + methods: { 196 + plc: new PlcDidDocumentResolver(), 197 + web: new WebDidDocumentResolver(), 198 + }, 199 + }), 200 + }), 201 + }); 202 + ``` 203 + 204 + ### serve metadata and jwks 205 + 206 + the PDS/authorization server fetches your client metadata and JWKS from the URLs you advertise: 207 + 208 + ```ts 209 + app.get('/oauth-client-metadata.json', (c) => c.json(oauth.metadata)); 210 + app.get('/jwks.json', (c) => c.json(oauth.jwks)); 211 + ``` 212 + 213 + ### start authorization 214 + 215 + ```ts 216 + app.get('/login', async (c) => { 217 + const { url } = await oauth.authorize({ 218 + target: { type: 'account', identifier: 'mary.my.id' }, 219 + state: { returnTo: '/protected' }, 220 + }); 221 + 222 + return c.redirect(url.toString()); 223 + }); 224 + ``` 225 + 226 + ### handle the callback 227 + 228 + pass the callback query params to `callback()`. if your framework only gives you a path, combine it 229 + with your public origin (the same origin used in your `redirect_uri`): 230 + 231 + ```ts 232 + app.get('/oauth/callback', async (c) => { 233 + const callbackUrl = new URL(c.req.url); 234 + const { session, state } = await oauth.callback(callbackUrl.searchParams); 235 + 236 + // store session.did in your own cookie/session so you know who is signed in. 237 + // oauth tokens are stored in your session store - don't store them elsewhere. 238 + const did = session.did; 239 + const returnTo = (state as { returnTo?: string } | undefined)?.returnTo ?? '/'; 240 + 241 + void did; 242 + return c.redirect(returnTo); 243 + }); 244 + ``` 245 + 246 + ### session restoration 247 + 248 + restore a session by DID. this will refresh tokens if needed. 249 + 250 + ```ts 251 + import { Client } from '@atcute/client'; 252 + 253 + const session = await oauth.restore(did); 254 + const rpc = new Client({ handler: session }); 255 + 256 + const { data } = await rpc.get('com.atproto.server.getSession'); 257 + ``` 258 + 259 + ### signing out 260 + 261 + ```ts 262 + await oauth.revoke(did); 263 + ``` 264 + 265 + or, if you already have an `OAuthSession`: 266 + 267 + ```ts 268 + await session.signOut(); 269 + ```
+112
packages/oauth/node-client/lib/build-client-metadata.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { generatePrivateKey } from './keyset/import-key.js'; 4 + import { Keyset } from './keyset/keyset.js'; 5 + import type { ConfidentialClientMetadata } from './schemas/atcute-confidential-client-metadata.js'; 6 + 7 + import { buildClientMetadata } from './build-client-metadata.js'; 8 + 9 + const createValidMetadata = (): ConfidentialClientMetadata => ({ 10 + client_id: 'https://example.com/client-metadata.json', 11 + client_name: 'Test Client', 12 + client_uri: 'https://example.com', 13 + redirect_uris: ['https://example.com/callback'], 14 + scope: 'atproto', 15 + }); 16 + 17 + describe('buildClientMetadata', () => { 18 + describe('valid metadata', () => { 19 + it('accepts valid metadata with ES256 key', async () => { 20 + const key = await generatePrivateKey('key-1', 'ES256'); 21 + const keyset = new Keyset([key]); 22 + const metadata = createValidMetadata(); 23 + 24 + const result = buildClientMetadata(metadata, keyset); 25 + 26 + expect(result.client_id).toBe('https://example.com/client-metadata.json'); 27 + expect(result.token_endpoint_auth_method).toBe('private_key_jwt'); 28 + expect(result.dpop_bound_access_tokens).toBe(true); 29 + }); 30 + 31 + it('populates jwks from keyset if jwks_uri is not provided', async () => { 32 + const key = await generatePrivateKey('key-1', 'ES256'); 33 + const keyset = new Keyset([key]); 34 + const metadata = createValidMetadata(); 35 + 36 + const result = buildClientMetadata(metadata, keyset); 37 + 38 + expect(result.jwks).toBeDefined(); 39 + expect(result.jwks!.keys).toHaveLength(1); 40 + expect(result.jwks!.keys[0].kid).toBe('key-1'); 41 + }); 42 + 43 + it('uses jwks_uri when provided', async () => { 44 + const key = await generatePrivateKey('key-1', 'ES256'); 45 + const keyset = new Keyset([key]); 46 + const metadata: ConfidentialClientMetadata = { 47 + ...createValidMetadata(), 48 + jwks_uri: 'https://example.com/.well-known/jwks.json', 49 + }; 50 + 51 + const result = buildClientMetadata(metadata, keyset); 52 + expect(result.jwks_uri).toBe('https://example.com/.well-known/jwks.json'); 53 + expect(result.jwks).toBeUndefined(); 54 + }); 55 + 56 + it('supports multiple keys (including ES256)', async () => { 57 + const key1 = await generatePrivateKey('key-1', 'ES256'); 58 + const key2 = await generatePrivateKey('key-2', 'ES384'); 59 + const keyset = new Keyset([key1, key2]); 60 + const metadata = createValidMetadata(); 61 + 62 + const result = buildClientMetadata(metadata, keyset); 63 + 64 + expect(result.jwks!.keys).toHaveLength(2); 65 + }); 66 + }); 67 + 68 + describe('invalid metadata', () => { 69 + it('rejects keyset without ES256 key', async () => { 70 + const key = await generatePrivateKey('key-1', 'ES384'); 71 + const keyset = new Keyset([key]); 72 + const metadata = createValidMetadata(); 73 + 74 + expect(() => buildClientMetadata(metadata, keyset)).toThrow( 75 + '"private_key_jwt" requires at least one "ES256" signing key', 76 + ); 77 + }); 78 + 79 + it('rejects loopback client_id', async () => { 80 + const key = await generatePrivateKey('key-1', 'ES256'); 81 + const keyset = new Keyset([key]); 82 + const metadata: ConfidentialClientMetadata = { 83 + ...createValidMetadata(), 84 + client_id: 'http://127.0.0.1:8080/callback', 85 + }; 86 + 87 + expect(() => buildClientMetadata(metadata, keyset)).toThrow(); 88 + }); 89 + 90 + it('rejects missing atproto scope', async () => { 91 + const key = await generatePrivateKey('key-1', 'ES256'); 92 + const keyset = new Keyset([key]); 93 + const metadata: ConfidentialClientMetadata = { 94 + ...createValidMetadata(), 95 + scope: 'openid profile', 96 + }; 97 + 98 + expect(() => buildClientMetadata(metadata, keyset)).toThrow(); 99 + }); 100 + 101 + it('rejects jwks_uri with different origin', async () => { 102 + const key = await generatePrivateKey('key-1', 'ES256'); 103 + const keyset = new Keyset([key]); 104 + const metadata: ConfidentialClientMetadata = { 105 + ...createValidMetadata(), 106 + jwks_uri: 'https://other.example/.well-known/jwks.json', 107 + }; 108 + 109 + expect(() => buildClientMetadata(metadata, keyset)).toThrow(); 110 + }); 111 + }); 112 + });
+71
packages/oauth/node-client/lib/build-client-metadata.ts
··· 1 + import { FALLBACK_ALG } from './constants.js'; 2 + import type { Keyset } from './keyset/keyset.js'; 3 + import { 4 + confidentialClientMetadataSchema, 5 + type ConfidentialClientMetadata, 6 + } from './schemas/atcute-confidential-client-metadata.js'; 7 + import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 8 + 9 + /** 10 + * builds an atproto client metadata 11 + * 12 + * 13 + * @param input client metadata 14 + * @param keyset available keys 15 + * @returns built client metadata 16 + */ 17 + export const buildClientMetadata = ( 18 + input: ConfidentialClientMetadata, 19 + keyset: Keyset, 20 + ): OAuthClientMetadata => { 21 + // validate user-facing schema is correct 22 + const conf = confidentialClientMetadataSchema.parse(input, { mode: 'passthrough' }); 23 + 24 + // build full OAuth client metadata (atproto defaults and requirements) 25 + const metadata: OAuthClientMetadata = { 26 + client_id: conf.client_id, 27 + client_name: conf.client_name, 28 + client_uri: conf.client_uri, 29 + policy_uri: conf.policy_uri, 30 + tos_uri: conf.tos_uri, 31 + logo_uri: conf.logo_uri, 32 + redirect_uris: conf.redirect_uris, 33 + scope: conf.scope, 34 + 35 + application_type: 'web', 36 + subject_type: 'public', 37 + response_types: ['code'], 38 + grant_types: ['authorization_code', 'refresh_token'], 39 + 40 + token_endpoint_auth_method: 'private_key_jwt', 41 + token_endpoint_auth_signing_alg: FALLBACK_ALG, 42 + dpop_bound_access_tokens: true, 43 + 44 + jwks_uri: conf.jwks_uri, 45 + jwks: conf.jwks_uri ? undefined : (keyset.publicJwks as OAuthClientMetadata['jwks']), 46 + }; 47 + 48 + // ensure at least one key supports the fallback algorithm 49 + const signingKeys = Array.from(keyset); 50 + if (!signingKeys.some((key) => key.alg === FALLBACK_ALG)) { 51 + throw new TypeError(`"private_key_jwt" requires at least one "${FALLBACK_ALG}" signing key`); 52 + } 53 + 54 + // if jwks provided inline, ensure ALL signing keys are present 55 + if (metadata.jwks) { 56 + const jwksKids = new Set( 57 + metadata.jwks.keys 58 + .filter((k) => !k.revoked) 59 + .map((k) => k.kid) 60 + .filter(Boolean), 61 + ); 62 + 63 + for (const key of signingKeys) { 64 + if (!jwksKids.has(key.kid)) { 65 + throw new TypeError(`signing key "${key.kid}" not found in jwks`); 66 + } 67 + } 68 + } 69 + 70 + return metadata; 71 + };
+20
packages/oauth/node-client/lib/constants.ts
··· 1 + /** regex for matching JSON content-type */ 2 + export const JSON_MIME = /^application\/json(;|$)/; 3 + 4 + /** max size for AS metadata responses (~1.6KB avg, 3x buffer) */ 5 + export const AS_METADATA_MAX_SIZE = 8 * 1024; // 8KB 6 + 7 + /** max size for protected resource metadata responses (~200B avg, 3x buffer) */ 8 + export const PR_METADATA_MAX_SIZE = 1024; // 1KB 9 + 10 + /** max size for token endpoint responses */ 11 + export const TOKEN_RESPONSE_MAX_SIZE = 8 * 1024; // 8KB 12 + 13 + /** max size for PAR responses */ 14 + export const PAR_RESPONSE_MAX_SIZE = 1024; // 1KB 15 + 16 + /** default algorithm per atproto spec */ 17 + export const FALLBACK_ALG = 'ES256'; 18 + 19 + /** JWT bearer assertion type for `private_key_jwt` authentication */ 20 + export const CLIENT_ASSERTION_TYPE_JWT_BEARER = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
+201
packages/oauth/node-client/lib/dpop/fetch-dpop.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + 3 + import { MemoryStore } from '../utils/memory-store.js'; 4 + 5 + import { createDpopFetch } from './fetch-dpop.js'; 6 + import { generateDpopKey } from './generate-key.js'; 7 + 8 + const createMockResponse = ( 9 + status: number, 10 + body?: unknown, 11 + headers?: Record<string, string>, 12 + ): Response => { 13 + return new Response(body ? JSON.stringify(body) : null, { 14 + status, 15 + headers: { 16 + 'Content-Type': 'application/json', 17 + ...headers, 18 + }, 19 + }); 20 + }; 21 + 22 + describe('createDpopFetch', () => { 23 + describe('basic DPoP proof', () => { 24 + it('should add DPoP header to requests', async () => { 25 + const key = await generateDpopKey(); 26 + const nonces = new MemoryStore<string, string>({}); 27 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200, { ok: true })); 28 + 29 + const dpopFetch = createDpopFetch({ key, nonces, fetch: mockFetch }); 30 + 31 + await dpopFetch('https://example.com/api'); 32 + 33 + expect(mockFetch).toHaveBeenCalledTimes(1); 34 + const request = mockFetch.mock.calls[0][0] as Request; 35 + expect(request.headers.get('DPoP')).toBeTruthy(); 36 + }); 37 + 38 + it('should include method and URL in DPoP proof', async () => { 39 + const key = await generateDpopKey(); 40 + const nonces = new MemoryStore<string, string>({}); 41 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200)); 42 + 43 + const dpopFetch = createDpopFetch({ key, nonces, fetch: mockFetch }); 44 + 45 + await dpopFetch('https://example.com/api', { method: 'POST' }); 46 + 47 + const request = mockFetch.mock.calls[0][0] as Request; 48 + const dpopProof = request.headers.get('DPoP')!; 49 + 50 + // DPoP proof is a JWT - verify it has 3 parts 51 + expect(dpopProof.split('.').length).toBe(3); 52 + }); 53 + }); 54 + 55 + describe('nonce handling', () => { 56 + it('should cache nonce from response', async () => { 57 + const key = await generateDpopKey(); 58 + const nonces = new MemoryStore<string, string>({}); 59 + const mockFetch = vi.fn().mockResolvedValue( 60 + createMockResponse(200, { ok: true }, { 'DPoP-Nonce': 'server-nonce-123' }), 61 + ); 62 + 63 + const dpopFetch = createDpopFetch({ key, nonces, fetch: mockFetch }); 64 + 65 + await dpopFetch('https://example.com/api'); 66 + 67 + expect(await nonces.get('https://example.com')).toBe('server-nonce-123'); 68 + }); 69 + 70 + it('should use cached nonce in subsequent requests', async () => { 71 + const key = await generateDpopKey(); 72 + const nonces = new MemoryStore<string, string>({}); 73 + await nonces.set('https://example.com', 'cached-nonce'); 74 + 75 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200)); 76 + 77 + const dpopFetch = createDpopFetch({ key, nonces, fetch: mockFetch }); 78 + 79 + await dpopFetch('https://example.com/api'); 80 + 81 + // the proof should contain the cached nonce 82 + // (we can't easily verify the JWT contents, but we can verify the request was made) 83 + expect(mockFetch).toHaveBeenCalledTimes(1); 84 + }); 85 + 86 + it('should retry with new nonce on use_dpop_nonce error (resource server)', async () => { 87 + const key = await generateDpopKey(); 88 + const nonces = new MemoryStore<string, string>({}); 89 + 90 + const mockFetch = vi 91 + .fn() 92 + .mockResolvedValueOnce( 93 + createMockResponse( 94 + 401, 95 + {}, 96 + { 97 + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 98 + 'DPoP-Nonce': 'new-nonce', 99 + }, 100 + ), 101 + ) 102 + .mockResolvedValueOnce(createMockResponse(200, { ok: true })); 103 + 104 + const dpopFetch = createDpopFetch({ key, nonces, isAuthServer: false, fetch: mockFetch }); 105 + 106 + const response = await dpopFetch('https://example.com/api'); 107 + 108 + expect(mockFetch).toHaveBeenCalledTimes(2); 109 + expect(response.status).toBe(200); 110 + expect(await nonces.get('https://example.com')).toBe('new-nonce'); 111 + }); 112 + 113 + it('should retry with new nonce on use_dpop_nonce error (auth server)', async () => { 114 + const key = await generateDpopKey(); 115 + const nonces = new MemoryStore<string, string>({}); 116 + 117 + const mockFetch = vi 118 + .fn() 119 + .mockResolvedValueOnce( 120 + createMockResponse(400, { error: 'use_dpop_nonce' }, { 'DPoP-Nonce': 'new-nonce' }), 121 + ) 122 + .mockResolvedValueOnce(createMockResponse(200, { access_token: 'token' })); 123 + 124 + const dpopFetch = createDpopFetch({ key, nonces, isAuthServer: true, fetch: mockFetch }); 125 + 126 + const response = await dpopFetch('https://auth.example.com/token', { method: 'POST' }); 127 + 128 + expect(mockFetch).toHaveBeenCalledTimes(2); 129 + expect(response.status).toBe(200); 130 + }); 131 + }); 132 + 133 + describe('access token hash (ath)', () => { 134 + it('should include ath when Authorization header has DPoP token', async () => { 135 + const key = await generateDpopKey(); 136 + const nonces = new MemoryStore<string, string>({}); 137 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200)); 138 + 139 + const dpopFetch = createDpopFetch({ key, nonces, fetch: mockFetch }); 140 + 141 + await dpopFetch('https://example.com/api', { 142 + headers: { Authorization: 'DPoP access-token-here' }, 143 + }); 144 + 145 + // verify request was made with DPoP header 146 + const request = mockFetch.mock.calls[0][0] as Request; 147 + expect(request.headers.get('DPoP')).toBeTruthy(); 148 + expect(request.headers.get('Authorization')).toBe('DPoP access-token-here'); 149 + }); 150 + }); 151 + 152 + describe('error cases', () => { 153 + it('should throw if key has no alg', async () => { 154 + const key = { kty: 'EC', crv: 'P-256', x: 'x', y: 'y', d: 'd' }; // no alg 155 + const nonces = new MemoryStore<string, string>({}); 156 + 157 + expect(() => createDpopFetch({ key, nonces })).toThrow("DPoP key must have 'alg' field set"); 158 + }); 159 + 160 + it('should throw if key alg not supported by server', async () => { 161 + const key = await generateDpopKey(['ES256']); 162 + const nonces = new MemoryStore<string, string>({}); 163 + 164 + expect(() => 165 + createDpopFetch({ 166 + key, 167 + nonces, 168 + supportedAlgs: ['RS256', 'RS512'], // server doesn't support ES256 169 + }), 170 + ).toThrow('not supported by server'); 171 + }); 172 + }); 173 + 174 + describe('URL handling', () => { 175 + it('should strip query string from htu', async () => { 176 + const key = await generateDpopKey(); 177 + const nonces = new MemoryStore<string, string>({}); 178 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200)); 179 + 180 + const dpopFetch = createDpopFetch({ key, nonces, fetch: mockFetch }); 181 + 182 + await dpopFetch('https://example.com/api?foo=bar&baz=qux'); 183 + 184 + expect(mockFetch).toHaveBeenCalledTimes(1); 185 + // the htu in the JWT should not include query string 186 + // (verified implicitly by successful request) 187 + }); 188 + 189 + it('should strip fragment from htu', async () => { 190 + const key = await generateDpopKey(); 191 + const nonces = new MemoryStore<string, string>({}); 192 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200)); 193 + 194 + const dpopFetch = createDpopFetch({ key, nonces, fetch: mockFetch }); 195 + 196 + await dpopFetch('https://example.com/api#section'); 197 + 198 + expect(mockFetch).toHaveBeenCalledTimes(1); 199 + }); 200 + }); 201 + });
+236
packages/oauth/node-client/lib/dpop/fetch-dpop.ts
··· 1 + import { type JWK, SignJWT } from 'jose'; 2 + import { nanoid } from 'nanoid'; 3 + 4 + import type { Store } from '../utils/store.js'; 5 + import { sha256 } from '../utils/crypto.js'; 6 + 7 + /** DPoP nonce cache, keyed by origin */ 8 + export type DpopNonceCache = Store<string, string>; 9 + 10 + /** cache for derived public JWKs */ 11 + const publicJwkCache = new WeakMap<JWK, JWK>(); 12 + 13 + /** 14 + * derives the public JWK from a private JWK, with caching. 15 + */ 16 + const getPublicJwk = (privateJwk: JWK): JWK => { 17 + let publicJwk = publicJwkCache.get(privateJwk); 18 + if (!publicJwk) { 19 + const { kty } = privateJwk; 20 + 21 + if (kty === 'EC') { 22 + const { alg, crv, x, y } = privateJwk as JWK & { crv: string; x: string; y: string }; 23 + publicJwk = { kty, alg, crv, x, y }; 24 + } else if (kty === 'RSA') { 25 + const { alg, n, e } = privateJwk as JWK & { n: string; e: string }; 26 + publicJwk = { kty, alg, n, e }; 27 + } else if (kty === 'OKP') { 28 + const { alg, crv, x } = privateJwk as JWK & { crv: string; x: string }; 29 + publicJwk = { kty, alg, crv, x }; 30 + } else { 31 + throw new Error(`unsupported key type: ${kty}`); 32 + } 33 + 34 + publicJwkCache.set(privateJwk, publicJwk); 35 + } 36 + 37 + return publicJwk; 38 + }; 39 + 40 + export interface DpopFetchOptions { 41 + /** DPoP private key (JWK with `alg` set) */ 42 + key: JWK; 43 + /** nonce store, keyed by origin */ 44 + nonces: DpopNonceCache; 45 + /** server's supported DPoP signing algorithms */ 46 + supportedAlgs?: readonly string[]; 47 + /** 48 + * is the target an authorization server (true) or resource server (false)? 49 + * affects how `use_dpop_nonce` errors are detected. 50 + */ 51 + isAuthServer?: boolean; 52 + /** custom fetch implementation */ 53 + fetch?: typeof globalThis.fetch; 54 + } 55 + 56 + /** 57 + * creates a fetch wrapper that adds DPoP proofs to requests. 58 + * 59 + * @param options DPoP configuration 60 + * @returns fetch function with DPoP support 61 + */ 62 + export const createDpopFetch = (options: DpopFetchOptions): typeof globalThis.fetch => { 63 + const { key, nonces, supportedAlgs, isAuthServer, fetch = globalThis.fetch } = options; 64 + 65 + // negotiate/validate algorithm 66 + const alg = negotiateAlg(key, supportedAlgs); 67 + 68 + return async (input, init) => { 69 + const request: Request = init == null && input instanceof Request ? input : new Request(input, init); 70 + 71 + // compute ath (access token hash) if Authorization header has DPoP token 72 + const authHeader = request.headers.get('Authorization'); 73 + const ath = authHeader?.startsWith('DPoP ') ? await sha256(authHeader.slice(5)) : undefined; 74 + 75 + const { origin } = new URL(request.url); 76 + const htm = request.method; 77 + const htu = buildHtu(request.url); 78 + 79 + // get cached nonce for this origin 80 + let initNonce: string | undefined; 81 + try { 82 + initNonce = await nonces.get(origin); 83 + } catch { 84 + // ignore get errors 85 + } 86 + 87 + // build and send initial request with DPoP proof 88 + const initProof = await buildProof(key, alg, htm, htu, initNonce, ath); 89 + request.headers.set('DPoP', initProof); 90 + 91 + const initResponse = await fetch(request); 92 + 93 + // check for new nonce in response 94 + const nextNonce = initResponse.headers.get('DPoP-Nonce'); 95 + if (!nextNonce || nextNonce === initNonce) { 96 + return initResponse; 97 + } 98 + 99 + // store the new nonce 100 + try { 101 + await nonces.set(origin, nextNonce); 102 + } catch { 103 + // ignore set errors 104 + } 105 + 106 + // check if we need to retry with the new nonce 107 + const shouldRetry = await isUseDpopNonceError(initResponse, isAuthServer); 108 + if (!shouldRetry) { 109 + return initResponse; 110 + } 111 + 112 + // can't retry if request body was already consumed 113 + if (input === request) { 114 + return initResponse; 115 + } 116 + if (init?.body instanceof ReadableStream) { 117 + return initResponse; 118 + } 119 + 120 + // consume the initial response body before retrying 121 + await initResponse.body?.cancel(); 122 + 123 + // retry with the new nonce 124 + const nextProof = await buildProof(key, alg, htm, htu, nextNonce, ath); 125 + const nextRequest = new Request(input, init); 126 + nextRequest.headers.set('DPoP', nextProof); 127 + 128 + const retryResponse = await fetch(nextRequest); 129 + 130 + // update nonce from retry response if present 131 + const retryNonce = retryResponse.headers.get('DPoP-Nonce'); 132 + if (retryNonce && retryNonce !== nextNonce) { 133 + try { 134 + await nonces.set(origin, retryNonce); 135 + } catch { 136 + // ignore set errors 137 + } 138 + } 139 + 140 + return retryResponse; 141 + }; 142 + }; 143 + 144 + /** 145 + * strips query string and fragment from URL for the `htu` claim. 146 + * 147 + * @see {@link https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6} 148 + */ 149 + const buildHtu = (url: string): string => { 150 + const fragmentIdx = url.indexOf('#'); 151 + const queryIdx = url.indexOf('?'); 152 + 153 + const end = fragmentIdx === -1 ? queryIdx : queryIdx === -1 ? fragmentIdx : Math.min(fragmentIdx, queryIdx); 154 + 155 + return end === -1 ? url : url.slice(0, end); 156 + }; 157 + 158 + /** 159 + * builds a DPoP proof JWT. 160 + */ 161 + const buildProof = async ( 162 + key: JWK, 163 + alg: string, 164 + htm: string, 165 + htu: string, 166 + nonce?: string, 167 + ath?: string, 168 + ): Promise<string> => { 169 + const now = Math.floor(Date.now() / 1_000); 170 + 171 + return new SignJWT({ 172 + htm, 173 + htu, 174 + iat: now, 175 + jti: nanoid(24), 176 + nonce, 177 + ath, 178 + }) 179 + .setProtectedHeader({ 180 + alg, 181 + typ: 'dpop+jwt', 182 + jwk: getPublicJwk(key), 183 + }) 184 + .sign(key); 185 + }; 186 + 187 + /** 188 + * negotiates the DPoP signing algorithm based on key and server support. 189 + */ 190 + const negotiateAlg = (key: JWK, supportedAlgs?: readonly string[]): string => { 191 + const keyAlg = key.alg; 192 + if (!keyAlg) { 193 + throw new Error(`DPoP key must have 'alg' field set`); 194 + } 195 + 196 + if (supportedAlgs?.length) { 197 + if (supportedAlgs.includes(keyAlg)) { 198 + return keyAlg; 199 + } 200 + throw new Error(`DPoP key algorithm ${keyAlg} not supported by server: ${supportedAlgs.join(', ')}`); 201 + } 202 + return keyAlg; 203 + }; 204 + 205 + /** 206 + * checks if the response is a `use_dpop_nonce` error. 207 + */ 208 + const isUseDpopNonceError = async (response: Response, isAuthServer?: boolean): Promise<boolean> => { 209 + // resource server: WWW-Authenticate header with use_dpop_nonce error 210 + // https://datatracker.ietf.org/doc/html/rfc6750#section-3 211 + // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no 212 + if (isAuthServer === undefined || isAuthServer === false) { 213 + if (response.status === 401) { 214 + const wwwAuth = response.headers.get('WWW-Authenticate'); 215 + if (wwwAuth?.startsWith('DPoP')) { 216 + return wwwAuth.includes('error="use_dpop_nonce"'); 217 + } 218 + } 219 + } 220 + 221 + // authorization server: JSON body with error field 222 + // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid 223 + if (isAuthServer === undefined || isAuthServer === true) { 224 + if (response.status === 400) { 225 + try { 226 + // clone to preserve body for caller 227 + const json = await response.clone().json(); 228 + return typeof json === 'object' && json?.error === 'use_dpop_nonce'; 229 + } catch { 230 + return false; 231 + } 232 + } 233 + } 234 + 235 + return false; 236 + };
+51
packages/oauth/node-client/lib/dpop/generate-key.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { generateDpopKey } from './generate-key.js'; 4 + 5 + describe('generateDpopKey', () => { 6 + it('should generate ES256 key by default', async () => { 7 + const key = await generateDpopKey(); 8 + 9 + expect(key.alg).toBe('ES256'); 10 + expect(key.kty).toBe('EC'); 11 + expect(key.crv).toBe('P-256'); 12 + expect(key.d).toBeDefined(); // private key component 13 + }); 14 + 15 + it('should prefer ES256K when supported', async () => { 16 + // ES256K may not be supported in all environments, so this might fall back 17 + const key = await generateDpopKey(['ES256K', 'ES256']); 18 + 19 + // should be one of the supported algorithms 20 + expect(['ES256K', 'ES256']).toContain(key.alg); 21 + }); 22 + 23 + it('should prefer ES over PS over RS', async () => { 24 + const key = await generateDpopKey(['RS256', 'PS256', 'ES384']); 25 + 26 + // ES384 should be preferred over PS256 and RS256 27 + expect(key.alg).toBe('ES384'); 28 + }); 29 + 30 + it('should prefer shorter key lengths within same family', async () => { 31 + const key = await generateDpopKey(['ES512', 'ES384', 'ES256']); 32 + 33 + // ES256 should be preferred (shorter = faster) 34 + expect(key.alg).toBe('ES256'); 35 + }); 36 + 37 + it('should throw when no algorithms work', async () => { 38 + await expect(generateDpopKey(['INVALID_ALG'])).rejects.toThrow('failed to generate DPoP key'); 39 + }); 40 + 41 + it('should include all required JWK fields', async () => { 42 + const key = await generateDpopKey(); 43 + 44 + expect(key.kty).toBeDefined(); 45 + expect(key.alg).toBeDefined(); 46 + // EC keys have x, y, d 47 + expect(key.x).toBeDefined(); 48 + expect(key.y).toBeDefined(); 49 + expect(key.d).toBeDefined(); 50 + }); 51 + });
+71
packages/oauth/node-client/lib/dpop/generate-key.ts
··· 1 + import { type JWK, exportJWK, generateKeyPair } from 'jose'; 2 + 3 + /** 4 + * preferred algorithm order for DPoP key generation. 5 + * ES256K > ES (shorter first) > PS (shorter first) > RS (shorter first) 6 + */ 7 + const PREFERRED_ALGORITHMS = [ 8 + 'ES256K', 9 + 'ES256', 10 + 'ES384', 11 + 'ES512', 12 + 'PS256', 13 + 'PS384', 14 + 'PS512', 15 + 'RS256', 16 + 'RS384', 17 + 'RS512', 18 + ] as const; 19 + 20 + /** 21 + * sorts algorithms by preference order. 22 + * ES256K > ES (shorter first) > PS (shorter first) > RS (shorter first) > other 23 + */ 24 + const sortAlgorithms = (algs: readonly string[]): string[] => { 25 + return [...algs].sort((a, b) => { 26 + const aIdx = PREFERRED_ALGORITHMS.indexOf(a as (typeof PREFERRED_ALGORITHMS)[number]); 27 + const bIdx = PREFERRED_ALGORITHMS.indexOf(b as (typeof PREFERRED_ALGORITHMS)[number]); 28 + 29 + // known algorithms come before unknown 30 + if (aIdx === -1 && bIdx === -1) { 31 + return 0; 32 + } 33 + if (aIdx === -1) { 34 + return 1; 35 + } 36 + if (bIdx === -1) { 37 + return -1; 38 + } 39 + 40 + return aIdx - bIdx; 41 + }); 42 + }; 43 + 44 + /** 45 + * generates a new DPoP key (private JWK with `alg` set). 46 + * 47 + * @param supportedAlgs algorithms supported by the server (from `dpop_signing_alg_values_supported`) 48 + * @returns private JWK with `alg` field set 49 + */ 50 + export const generateDpopKey = async (supportedAlgs?: readonly string[]): Promise<JWK> => { 51 + // default to ES256 per atproto spec 52 + const algs = supportedAlgs?.length ? sortAlgorithms(supportedAlgs) : ['ES256']; 53 + 54 + const errors: unknown[] = []; 55 + 56 + for (const alg of algs) { 57 + try { 58 + const { privateKey } = await generateKeyPair(alg, { extractable: true }); 59 + 60 + // export to JWK for storage 61 + const jwk = await exportJWK(privateKey); 62 + jwk.alg = alg; 63 + 64 + return jwk; 65 + } catch (err) { 66 + errors.push(err); 67 + } 68 + } 69 + 70 + throw new AggregateError(errors, `failed to generate DPoP key for any of: ${algs.join(', ')}`); 71 + };
+93
packages/oauth/node-client/lib/errors.ts
··· 1 + /** 2 + * thrown when client authentication method is no longer usable 3 + * (e.g., key removed from keyset or server no longer supports method). 4 + */ 5 + export class AuthMethodUnsatisfiableError extends Error { 6 + override name = 'AuthMethodUnsatisfiableError'; 7 + } 8 + 9 + /** 10 + * thrown when a session is invalid and cannot be used. 11 + */ 12 + export class TokenInvalidError extends Error { 13 + override name = 'TokenInvalidError'; 14 + 15 + constructor( 16 + public readonly sub: string, 17 + message = `session for "${sub}" is invalid`, 18 + options?: ErrorOptions, 19 + ) { 20 + super(message, options); 21 + } 22 + } 23 + 24 + /** 25 + * thrown when token refresh fails. 26 + */ 27 + export class TokenRefreshError extends Error { 28 + override name = 'TokenRefreshError'; 29 + 30 + constructor( 31 + public readonly sub: string, 32 + message: string, 33 + options?: ErrorOptions, 34 + ) { 35 + super(message, options); 36 + } 37 + } 38 + 39 + /** 40 + * thrown when a session has been revoked. 41 + */ 42 + export class TokenRevokedError extends Error { 43 + override name = 'TokenRevokedError'; 44 + 45 + constructor( 46 + public readonly sub: string, 47 + message = `session for "${sub}" was revoked`, 48 + options?: ErrorOptions, 49 + ) { 50 + super(message, options); 51 + } 52 + } 53 + 54 + /** 55 + * thrown when OAuth response indicates an error. 56 + */ 57 + export class OAuthResponseError extends Error { 58 + override name = 'OAuthResponseError'; 59 + 60 + constructor( 61 + public readonly response: Response, 62 + public readonly error: string, 63 + public readonly errorDescription?: string, 64 + ) { 65 + super(errorDescription ?? error); 66 + } 67 + 68 + get status(): number { 69 + return this.response.status; 70 + } 71 + } 72 + 73 + /** 74 + * thrown when OAuth callback contains an error. 75 + */ 76 + export class OAuthCallbackError extends Error { 77 + override name = 'OAuthCallbackError'; 78 + 79 + constructor( 80 + public readonly error: string, 81 + public readonly errorDescription?: string, 82 + public readonly state?: string, 83 + ) { 84 + super(errorDescription ?? error); 85 + } 86 + } 87 + 88 + /** 89 + * thrown when metadata resolution fails. 90 + */ 91 + export class OAuthResolverError extends Error { 92 + override name = 'OAuthResolverError'; 93 + }
+52
packages/oauth/node-client/lib/index.ts
··· 1 + export { buildClientMetadata } from './build-client-metadata.js'; 2 + export { 3 + OAuthClient, 4 + type AuthorizationResult, 5 + type AuthorizeOptions, 6 + type AuthorizeTarget, 7 + type CallbackOptions, 8 + type CallbackResult, 9 + type OAuthClientOptions, 10 + type OAuthClientStores, 11 + type RestoreOptions, 12 + } from './oauth-client.js'; 13 + 14 + export { OAuthSession } from './oauth-session.js'; 15 + export type { SessionEvent, SessionEventListener } from './session-getter.js'; 16 + 17 + export { 18 + exportJwkKey, 19 + exportPkcs8Key, 20 + generatePrivateKey, 21 + importJwkKey, 22 + importPkcs8Key, 23 + } from './keyset/import-key.js'; 24 + export { Keyset } from './keyset/keyset.js'; 25 + export type { ImportKeyOptions, PrivateKey, SigningAlgorithm } from './keyset/types.js'; 26 + 27 + export { 28 + AuthMethodUnsatisfiableError, 29 + OAuthCallbackError, 30 + OAuthResolverError, 31 + OAuthResponseError, 32 + TokenInvalidError, 33 + TokenRefreshError, 34 + TokenRevokedError, 35 + } from './errors.js'; 36 + 37 + export type { LockFunction } from './utils/lock.js'; 38 + export { MemoryStore } from './utils/memory-store.js'; 39 + export type { Store } from './utils/store.js'; 40 + 41 + export type { DpopNonceCache } from './dpop/fetch-dpop.js'; 42 + export type { AuthorizationServerMetadataCache } from './resolvers/authorization-server-metadata.js'; 43 + export type { ProtectedResourceMetadataCache } from './resolvers/protected-resource-metadata.js'; 44 + export type { SessionStore, StoredSession } from './types/sessions.js'; 45 + export type { StateStore, StoredState } from './types/states.js'; 46 + export type { TokenSet } from './types/token-set.js'; 47 + 48 + export type { ConfidentialClientMetadata } from './schemas/atcute-confidential-client-metadata.js'; 49 + export type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js'; 50 + export type { AtprotoProtectedResourceMetadata } from './schemas/atproto-protected-resource-metadata.js'; 51 + export type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 52 + export type { OAuthResponseMode } from './schemas/oauth-response-mode.js';
+215
packages/oauth/node-client/lib/keyset/import-key.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { 4 + exportJwkKey, 5 + exportPkcs8Key, 6 + generatePrivateKey, 7 + importJwkKey, 8 + importPkcs8Key, 9 + } from './import-key.js'; 10 + 11 + describe('generatePrivateKey', () => { 12 + it('should generate ES256 key by default', async () => { 13 + const key = await generatePrivateKey('test-key'); 14 + 15 + expect(key.kid).toBe('test-key'); 16 + expect(key.alg).toBe('ES256'); 17 + expect(key.key).toBeInstanceOf(CryptoKey); 18 + expect(key.publicJwk.kty).toBe('EC'); 19 + expect(key.publicJwk.crv).toBe('P-256'); 20 + }); 21 + 22 + it('should generate key with specified algorithm', async () => { 23 + const key = await generatePrivateKey('test-key', 'ES384'); 24 + 25 + expect(key.alg).toBe('ES384'); 26 + expect(key.publicJwk.crv).toBe('P-384'); 27 + }); 28 + 29 + it('should not include private material in publicJwk', async () => { 30 + const key = await generatePrivateKey('test-key'); 31 + 32 + expect((key.publicJwk as Record<string, unknown>).d).toBeUndefined(); 33 + expect(key.publicJwk.x).toBeDefined(); 34 + expect(key.publicJwk.y).toBeDefined(); 35 + }); 36 + }); 37 + 38 + describe('importJwkKey', () => { 39 + it('should import JWK object', async () => { 40 + const original = await generatePrivateKey('test-key', 'ES256'); 41 + const jwk = await exportJwkKey(original); 42 + 43 + const imported = await importJwkKey(jwk); 44 + 45 + expect(imported.kid).toBe('test-key'); 46 + expect(imported.alg).toBe('ES256'); 47 + }); 48 + 49 + it('should import JSON string', async () => { 50 + const original = await generatePrivateKey('test-key'); 51 + const jwk = await exportJwkKey(original); 52 + const jsonStr = JSON.stringify(jwk); 53 + 54 + const imported = await importJwkKey(jsonStr); 55 + 56 + expect(imported.kid).toBe('test-key'); 57 + }); 58 + 59 + it('should allow overriding kid via options', async () => { 60 + const original = await generatePrivateKey('original-kid'); 61 + const jwk = await exportJwkKey(original); 62 + 63 + const imported = await importJwkKey(jwk, { kid: 'new-kid' }); 64 + 65 + expect(imported.kid).toBe('new-kid'); 66 + }); 67 + 68 + it('should allow overriding alg via options', async () => { 69 + const original = await generatePrivateKey('test-key', 'ES256'); 70 + const jwk = await exportJwkKey(original); 71 + 72 + // ES256 key can also work with ES256 alg override (same key) 73 + const imported = await importJwkKey(jwk, { alg: 'ES256' }); 74 + 75 + expect(imported.alg).toBe('ES256'); 76 + }); 77 + 78 + it('should infer algorithm from EC curve', async () => { 79 + const original = await generatePrivateKey('test-key', 'ES384'); 80 + const jwk = await exportJwkKey(original); 81 + delete jwk.alg; // remove alg to test inference 82 + 83 + const imported = await importJwkKey(jwk); 84 + 85 + expect(imported.alg).toBe('ES384'); 86 + }); 87 + 88 + it('should throw on missing kid', async () => { 89 + const original = await generatePrivateKey('test-key'); 90 + const jwk = await exportJwkKey(original); 91 + delete jwk.kid; 92 + 93 + await expect(importJwkKey(jwk)).rejects.toThrow('kid is required'); 94 + }); 95 + 96 + it('should throw on missing alg for RSA key', async () => { 97 + const original = await generatePrivateKey('test-key', 'RS256'); 98 + const jwk = await exportJwkKey(original); 99 + delete jwk.alg; 100 + 101 + await expect(importJwkKey(jwk)).rejects.toThrow('alg is required'); 102 + }); 103 + 104 + it('should throw on invalid JSON string', async () => { 105 + await expect(importJwkKey('not valid json')).rejects.toThrow('invalid JSON string'); 106 + }); 107 + 108 + it('should throw on non-private key', async () => { 109 + const original = await generatePrivateKey('test-key'); 110 + const publicJwk = { ...original.publicJwk }; 111 + 112 + await expect(importJwkKey(publicJwk)).rejects.toThrow("expected a private key (missing 'd' parameter)"); 113 + }); 114 + 115 + it('should throw on unsupported algorithm', async () => { 116 + const original = await generatePrivateKey('test-key'); 117 + const jwk = await exportJwkKey(original); 118 + 119 + await expect(importJwkKey(jwk, { alg: 'HS256' as any })).rejects.toThrow('unsupported algorithm'); 120 + }); 121 + }); 122 + 123 + describe('importPkcs8Key', () => { 124 + it('should import PKCS#8 PEM', async () => { 125 + const original = await generatePrivateKey('test-key', 'ES256'); 126 + const pem = await exportPkcs8Key(original); 127 + 128 + const imported = await importPkcs8Key(pem, { kid: 'imported-key', alg: 'ES256' }); 129 + 130 + expect(imported.kid).toBe('imported-key'); 131 + expect(imported.alg).toBe('ES256'); 132 + expect(imported.key).toBeInstanceOf(CryptoKey); 133 + }); 134 + 135 + it('should throw on unsupported algorithm', async () => { 136 + const original = await generatePrivateKey('test-key', 'ES256'); 137 + const pem = await exportPkcs8Key(original); 138 + 139 + await expect(importPkcs8Key(pem, { kid: 'key', alg: 'HS256' as any })).rejects.toThrow( 140 + 'unsupported algorithm', 141 + ); 142 + }); 143 + }); 144 + 145 + describe('round-trip exports', () => { 146 + it('should round-trip through JWK', async () => { 147 + const original = await generatePrivateKey('test-key', 'ES256'); 148 + const jwk = await exportJwkKey(original); 149 + const imported = await importJwkKey(jwk); 150 + 151 + expect(imported.kid).toBe(original.kid); 152 + expect(imported.alg).toBe(original.alg); 153 + expect(imported.publicJwk).toEqual(original.publicJwk); 154 + }); 155 + 156 + it('should round-trip through PKCS#8', async () => { 157 + const original = await generatePrivateKey('test-key', 'ES256'); 158 + const pem = await exportPkcs8Key(original); 159 + const imported = await importPkcs8Key(pem, { kid: 'test-key', alg: 'ES256' }); 160 + 161 + expect(imported.kid).toBe(original.kid); 162 + expect(imported.alg).toBe(original.alg); 163 + // public key should match 164 + expect(imported.publicJwk.x).toBe(original.publicJwk.x); 165 + expect(imported.publicJwk.y).toBe(original.publicJwk.y); 166 + }); 167 + 168 + it('should work with different EC algorithms', async () => { 169 + for (const alg of ['ES256', 'ES384', 'ES512'] as const) { 170 + const original = await generatePrivateKey(`key-${alg}`, alg); 171 + const jwk = await exportJwkKey(original); 172 + const imported = await importJwkKey(jwk); 173 + 174 + expect(imported.alg).toBe(alg); 175 + } 176 + }); 177 + 178 + it('should work with RSA algorithms', async () => { 179 + for (const alg of ['RS256', 'PS256'] as const) { 180 + const original = await generatePrivateKey(`key-${alg}`, alg); 181 + const jwk = await exportJwkKey(original); 182 + const imported = await importJwkKey(jwk); 183 + 184 + expect(imported.alg).toBe(alg); 185 + expect(imported.publicJwk.kty).toBe('RSA'); 186 + } 187 + }); 188 + }); 189 + 190 + describe('exportJwkKey', () => { 191 + it('should include kid and alg', async () => { 192 + const key = await generatePrivateKey('my-key', 'ES384'); 193 + const jwk = await exportJwkKey(key); 194 + 195 + expect(jwk.kid).toBe('my-key'); 196 + expect(jwk.alg).toBe('ES384'); 197 + }); 198 + 199 + it('should include private key material', async () => { 200 + const key = await generatePrivateKey('my-key'); 201 + const jwk = await exportJwkKey(key); 202 + 203 + expect(jwk.d).toBeDefined(); // private key component 204 + }); 205 + }); 206 + 207 + describe('exportPkcs8Key', () => { 208 + it('should return valid PEM format', async () => { 209 + const key = await generatePrivateKey('my-key'); 210 + const pem = await exportPkcs8Key(key); 211 + 212 + expect(pem).toContain('-----BEGIN PRIVATE KEY-----'); 213 + expect(pem).toContain('-----END PRIVATE KEY-----'); 214 + }); 215 + });
+198
packages/oauth/node-client/lib/keyset/import-key.ts
··· 1 + import { type JWK, exportJWK, exportPKCS8, generateKeyPair, importJWK, importPKCS8 } from 'jose'; 2 + 3 + import type { ImportKeyOptions, PrivateKey, SigningAlgorithm } from './types.js'; 4 + 5 + const SIGNING_ALGORITHMS: readonly SigningAlgorithm[] = [ 6 + 'ES256', 7 + 'ES384', 8 + 'ES512', 9 + 'PS256', 10 + 'PS384', 11 + 'PS512', 12 + 'RS256', 13 + 'RS384', 14 + 'RS512', 15 + ]; 16 + 17 + /** map EC curve to default algorithm */ 18 + const CURVE_TO_ALG: Record<string, SigningAlgorithm> = { 19 + 'P-256': 'ES256', 20 + 'P-384': 'ES384', 21 + 'P-521': 'ES512', 22 + }; 23 + 24 + const isSigningAlgorithm = (alg: string): alg is SigningAlgorithm => { 25 + return (SIGNING_ALGORITHMS as readonly string[]).includes(alg); 26 + }; 27 + 28 + /** 29 + * generates a new private key for use with `private_key_jwt`. 30 + * 31 + * @param kid key ID to assign to the generated key 32 + * @param alg signing algorithm (defaults to 'ES256') 33 + * @returns private key ready for use in keyset 34 + */ 35 + export const generatePrivateKey = async ( 36 + kid: string, 37 + alg: SigningAlgorithm = 'ES256', 38 + ): Promise<PrivateKey> => { 39 + const { privateKey } = await generateKeyPair(alg, { extractable: true }); 40 + const jwk = await exportJWK(privateKey); 41 + jwk.alg = alg; 42 + jwk.kid = kid; 43 + 44 + const publicJwk = derivePublicJwk(jwk, kid, alg); 45 + 46 + return { kid, alg, key: privateKey, publicJwk }; 47 + }; 48 + 49 + /** 50 + * imports a private key from a JWK object or JSON string. 51 + * 52 + * @param input JWK object or JSON string containing a JWK 53 + * @param options override or provide `kid` and `alg` 54 + * @returns private key ready for use in keyset 55 + * @throws if `kid` cannot be determined, `alg` cannot be determined/inferred, 56 + * or the key format is invalid 57 + * 58 + * resolution order: 59 + * - `kid`: `options.kid` ?? `input.kid` ?? error 60 + * - `alg`: `options.alg` ?? `input.alg` ?? inferred from curve ?? error 61 + * 62 + * algorithm inference (EC keys only): 63 + * - P-256 → ES256, P-384 → ES384, P-521 → ES512 64 + * - RSA keys require explicit `alg` (no inference possible) 65 + */ 66 + export const importJwkKey = async (input: JWK | string, options?: ImportKeyOptions): Promise<PrivateKey> => { 67 + let jwk: JWK; 68 + 69 + if (typeof input === 'string') { 70 + try { 71 + jwk = JSON.parse(input) as JWK; 72 + } catch { 73 + throw new Error(`invalid JSON string`); 74 + } 75 + } else if (typeof input === 'object' && input !== null && 'kty' in input) { 76 + jwk = input; 77 + } else { 78 + throw new Error(`invalid input: expected JWK object or JSON string`); 79 + } 80 + 81 + // resolve kid 82 + const kid = options?.kid ?? jwk.kid; 83 + if (!kid) { 84 + throw new Error(`kid is required: provide via options or include in JWK`); 85 + } 86 + 87 + // resolve alg 88 + let alg = options?.alg ?? jwk.alg; 89 + if (!alg) { 90 + // try to infer from EC curve 91 + const crv = (jwk as { crv?: string }).crv; 92 + if (crv && crv in CURVE_TO_ALG) { 93 + alg = CURVE_TO_ALG[crv]; 94 + } else { 95 + throw new Error( 96 + `alg is required: provide via options, include in JWK, or use an EC key with a known curve`, 97 + ); 98 + } 99 + } 100 + 101 + if (!isSigningAlgorithm(alg)) { 102 + throw new Error(`unsupported algorithm: ${alg}`); 103 + } 104 + 105 + // verify this is a private key (has 'd' parameter for asymmetric keys) 106 + if (!('d' in jwk) || !jwk.d) { 107 + throw new Error(`expected a private key (missing 'd' parameter)`); 108 + } 109 + 110 + // import the JWK 111 + const imported = await importJWK(jwk, alg); 112 + if (!(imported instanceof CryptoKey)) { 113 + throw new Error(`expected asymmetric key, got symmetric`); 114 + } 115 + 116 + // derive public JWK by removing private components 117 + const publicJwk = derivePublicJwk(jwk, kid, alg); 118 + 119 + return { kid, alg, key: imported, publicJwk }; 120 + }; 121 + 122 + /** 123 + * imports a private key from a PKCS#8 PEM string. 124 + * 125 + * @param pem PKCS#8 PEM string (starts with '-----BEGIN PRIVATE KEY-----') 126 + * @param options must include `kid` and `alg` 127 + * @returns private key ready for use in keyset 128 + */ 129 + export const importPkcs8Key = async ( 130 + pem: string, 131 + options: Required<ImportKeyOptions>, 132 + ): Promise<PrivateKey> => { 133 + const { kid, alg } = options; 134 + 135 + if (!isSigningAlgorithm(alg)) { 136 + throw new Error(`unsupported algorithm: ${alg}`); 137 + } 138 + 139 + const imported = await importPKCS8(pem, alg, { extractable: true }); 140 + if (!(imported instanceof CryptoKey)) { 141 + throw new Error(`expected asymmetric key, got symmetric`); 142 + } 143 + 144 + const jwk = await exportJWK(imported); 145 + jwk.alg = alg; 146 + jwk.kid = kid; 147 + 148 + const publicJwk = derivePublicJwk(jwk, kid, alg); 149 + 150 + return { kid, alg, key: imported, publicJwk }; 151 + }; 152 + 153 + /** 154 + * exports a private key to JWK format. 155 + * 156 + * @param key private key to export 157 + * @returns JWK with `kid` and `alg` set 158 + */ 159 + export const exportJwkKey = async (key: PrivateKey): Promise<JWK> => { 160 + const jwk = await exportJWK(key.key); 161 + jwk.kid = key.kid; 162 + jwk.alg = key.alg; 163 + return jwk; 164 + }; 165 + 166 + /** 167 + * exports a private key to PKCS#8 PEM format. 168 + * 169 + * @param key private key to export 170 + * @returns PKCS#8 PEM string 171 + */ 172 + export const exportPkcs8Key = async (key: PrivateKey): Promise<string> => { 173 + return exportPKCS8(key.key); 174 + }; 175 + 176 + /** 177 + * derives a public JWK from a private JWK by removing private key material. 178 + */ 179 + const derivePublicJwk = (privateJwk: JWK, kid: string, alg: string): JWK => { 180 + const { kty } = privateJwk; 181 + 182 + if (kty === 'EC') { 183 + const { crv, x, y } = privateJwk as JWK & { crv: string; x: string; y: string }; 184 + return { kty, crv, x, y, kid, alg, use: 'sig' }; 185 + } 186 + 187 + if (kty === 'RSA') { 188 + const { n, e } = privateJwk as JWK & { n: string; e: string }; 189 + return { kty, n, e, kid, alg, use: 'sig' }; 190 + } 191 + 192 + if (kty === 'OKP') { 193 + const { crv, x } = privateJwk as JWK & { crv: string; x: string }; 194 + return { kty, crv, x, kid, alg, use: 'sig' }; 195 + } 196 + 197 + throw new Error(`unsupported key type: ${kty}`); 198 + };
+185
packages/oauth/node-client/lib/keyset/keyset.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { generatePrivateKey } from './import-key.js'; 4 + import { Keyset } from './keyset.js'; 5 + 6 + describe('Keyset', () => { 7 + describe('constructor', () => { 8 + it('should create keyset with valid keys', async () => { 9 + const key = await generatePrivateKey('key-1'); 10 + const keyset = new Keyset([key]); 11 + 12 + expect(keyset.size).toBe(1); 13 + }); 14 + 15 + it('should reject empty keyset', () => { 16 + expect(() => new Keyset([])).toThrow('keyset must contain at least one key'); 17 + }); 18 + 19 + it('should reject duplicate key IDs', async () => { 20 + const key1 = await generatePrivateKey('same-id'); 21 + const key2 = await generatePrivateKey('same-id'); 22 + 23 + expect(() => new Keyset([key1, key2])).toThrow('duplicate key ID: same-id'); 24 + }); 25 + 26 + it('should accept multiple keys with different IDs', async () => { 27 + const key1 = await generatePrivateKey('key-1'); 28 + const key2 = await generatePrivateKey('key-2', 'ES384'); 29 + 30 + const keyset = new Keyset([key1, key2]); 31 + expect(keyset.size).toBe(2); 32 + }); 33 + }); 34 + 35 + describe('find', () => { 36 + it('should find key by kid', async () => { 37 + const key1 = await generatePrivateKey('key-1'); 38 + const key2 = await generatePrivateKey('key-2'); 39 + const keyset = new Keyset([key1, key2]); 40 + 41 + const found = keyset.find({ kid: 'key-2' }); 42 + expect(found?.kid).toBe('key-2'); 43 + }); 44 + 45 + it('should find key by alg', async () => { 46 + const key1 = await generatePrivateKey('key-1', 'ES256'); 47 + const key2 = await generatePrivateKey('key-2', 'ES384'); 48 + const keyset = new Keyset([key1, key2]); 49 + 50 + const found = keyset.find({ alg: 'ES384' }); 51 + expect(found?.kid).toBe('key-2'); 52 + }); 53 + 54 + it('should find key by alg array', async () => { 55 + const key1 = await generatePrivateKey('key-1', 'ES256'); 56 + const key2 = await generatePrivateKey('key-2', 'ES384'); 57 + const keyset = new Keyset([key1, key2]); 58 + 59 + const found = keyset.find({ alg: ['ES384', 'ES512'] }); 60 + expect(found?.kid).toBe('key-2'); 61 + }); 62 + 63 + it('should return undefined when not found', async () => { 64 + const key = await generatePrivateKey('key-1', 'ES256'); 65 + const keyset = new Keyset([key]); 66 + 67 + expect(keyset.find({ kid: 'nonexistent' })).toBeUndefined(); 68 + expect(keyset.find({ alg: 'RS256' })).toBeUndefined(); 69 + }); 70 + 71 + it('should return first key when no options provided', async () => { 72 + const key1 = await generatePrivateKey('key-1', 'ES256'); 73 + const key2 = await generatePrivateKey('key-2', 'ES384'); 74 + const keyset = new Keyset([key1, key2]); 75 + 76 + // should return based on algorithm preference order 77 + const found = keyset.find(); 78 + expect(found).toBeDefined(); 79 + }); 80 + }); 81 + 82 + describe('get', () => { 83 + it('should get key by kid', async () => { 84 + const key = await generatePrivateKey('key-1'); 85 + const keyset = new Keyset([key]); 86 + 87 + const found = keyset.get({ kid: 'key-1' }); 88 + expect(found.kid).toBe('key-1'); 89 + }); 90 + 91 + it('should throw when key not found', async () => { 92 + const key = await generatePrivateKey('key-1'); 93 + const keyset = new Keyset([key]); 94 + 95 + expect(() => keyset.get({ kid: 'nonexistent' })).toThrow('no key found matching: nonexistent'); 96 + expect(() => keyset.get({ alg: 'RS512' })).toThrow('no key found matching: RS512'); 97 + }); 98 + }); 99 + 100 + describe('list', () => { 101 + it('should list all keys when no options', async () => { 102 + const key1 = await generatePrivateKey('key-1'); 103 + const key2 = await generatePrivateKey('key-2'); 104 + const keyset = new Keyset([key1, key2]); 105 + 106 + const keys = [...keyset.list()]; 107 + expect(keys).toHaveLength(2); 108 + }); 109 + 110 + it('should filter by alg', async () => { 111 + const key1 = await generatePrivateKey('key-1', 'ES256'); 112 + const key2 = await generatePrivateKey('key-2', 'ES384'); 113 + const key3 = await generatePrivateKey('key-3', 'ES256'); 114 + const keyset = new Keyset([key1, key2, key3]); 115 + 116 + const keys = [...keyset.list({ alg: 'ES256' })]; 117 + expect(keys).toHaveLength(2); 118 + expect(keys.map((k) => k.kid)).toEqual(['key-1', 'key-3']); 119 + }); 120 + 121 + it('should sort by algorithm preference', async () => { 122 + const rsKey = await generatePrivateKey('rs-key', 'RS256'); 123 + const esKey = await generatePrivateKey('es-key', 'ES256'); 124 + const keyset = new Keyset([rsKey, esKey]); 125 + 126 + // ES256 should come before RS256 in preference order 127 + const keys = [...keyset.list()]; 128 + expect(keys[0].alg).toBe('ES256'); 129 + expect(keys[1].alg).toBe('RS256'); 130 + }); 131 + }); 132 + 133 + describe('findForSigning', () => { 134 + it('should find compatible key for server algs', async () => { 135 + const key = await generatePrivateKey('key-1', 'ES256'); 136 + const keyset = new Keyset([key]); 137 + 138 + const result = keyset.findForSigning(['ES256', 'ES384']); 139 + expect(result.key.kid).toBe('key-1'); 140 + expect(result.alg).toBe('ES256'); 141 + }); 142 + 143 + it('should default to ES256 when no server algs provided', async () => { 144 + const key = await generatePrivateKey('key-1', 'ES256'); 145 + const keyset = new Keyset([key]); 146 + 147 + const result = keyset.findForSigning(); 148 + expect(result.alg).toBe('ES256'); 149 + }); 150 + 151 + it('should throw when no compatible key', async () => { 152 + const key = await generatePrivateKey('key-1', 'ES384'); 153 + const keyset = new Keyset([key]); 154 + 155 + expect(() => keyset.findForSigning(['RS256', 'RS512'])).toThrow( 156 + 'no key found compatible with server algorithms', 157 + ); 158 + }); 159 + }); 160 + 161 + describe('publicJwks', () => { 162 + it('should return public keys only', async () => { 163 + const key = await generatePrivateKey('key-1', 'ES256'); 164 + const keyset = new Keyset([key]); 165 + 166 + const jwks = keyset.publicJwks; 167 + expect(jwks.keys).toHaveLength(1); 168 + expect(jwks.keys[0].kid).toBe('key-1'); 169 + expect(jwks.keys[0].kty).toBe('EC'); 170 + // should not have private key material 171 + expect((jwks.keys[0] as Record<string, unknown>).d).toBeUndefined(); 172 + }); 173 + }); 174 + 175 + describe('iteration', () => { 176 + it('should be iterable', async () => { 177 + const key1 = await generatePrivateKey('key-1'); 178 + const key2 = await generatePrivateKey('key-2'); 179 + const keyset = new Keyset([key1, key2]); 180 + 181 + const keys = [...keyset]; 182 + expect(keys).toHaveLength(2); 183 + }); 184 + }); 185 + });
+141
packages/oauth/node-client/lib/keyset/keyset.ts
··· 1 + import type { JWK } from 'jose'; 2 + 3 + import type { KeySearchOptions, PrivateKey } from './types.js'; 4 + 5 + /** 6 + * preferred algorithm order for signing. 7 + * EC algorithms first (smaller, faster), then PSS, then PKCS#1 v1.5. 8 + */ 9 + const PREFERRED_ALGORITHMS = [ 10 + 'ES256', 11 + 'ES384', 12 + 'ES512', 13 + 'PS256', 14 + 'PS384', 15 + 'PS512', 16 + 'RS256', 17 + 'RS384', 18 + 'RS512', 19 + ] as const; 20 + 21 + /** 22 + * a collection of private keys for client authentication. 23 + */ 24 + export class Keyset { 25 + private readonly keys: readonly PrivateKey[]; 26 + 27 + /** 28 + * creates a new keyset from an array of private keys. 29 + * 30 + * @param keys array of private keys (at least one required) 31 + * @throws if keyset is empty or contains duplicate key IDs 32 + */ 33 + constructor(keys: PrivateKey[]) { 34 + if (keys.length === 0) { 35 + throw new Error(`keyset must contain at least one key`); 36 + } 37 + 38 + // check for duplicate kids 39 + const kids = new Set<string>(); 40 + for (const key of keys) { 41 + if (kids.has(key.kid)) { 42 + throw new Error(`duplicate key ID: ${key.kid}`); 43 + } 44 + kids.add(key.kid); 45 + } 46 + 47 + this.keys = Object.freeze([...keys]); 48 + } 49 + 50 + /** number of keys in the keyset */ 51 + get size(): number { 52 + return this.keys.length; 53 + } 54 + 55 + /** 56 + * public JWKS for serving at client metadata or jwks_uri. 57 + * pre-computed at import time, safe to inline. 58 + */ 59 + get publicJwks(): { keys: readonly JWK[] } { 60 + return { keys: this.keys.map((k) => k.publicJwk) }; 61 + } 62 + 63 + /** 64 + * finds the first key matching the given criteria. 65 + * 66 + * @param options search criteria (kid and/or alg) 67 + * @returns matching key or undefined 68 + */ 69 + find(options?: KeySearchOptions): PrivateKey | undefined { 70 + for (const key of this.list(options)) { 71 + return key; 72 + } 73 + return undefined; 74 + } 75 + 76 + /** 77 + * gets a key matching the given criteria. 78 + * 79 + * @param options search criteria (kid and/or alg) 80 + * @returns matching key 81 + * @throws if no matching key is found 82 + */ 83 + get(options?: KeySearchOptions): PrivateKey { 84 + const key = this.find(options); 85 + if (!key) { 86 + const desc = options?.kid ?? options?.alg ?? 'any'; 87 + throw new Error(`no key found matching: ${desc}`); 88 + } 89 + return key; 90 + } 91 + 92 + /** 93 + * iterates over keys matching the given criteria, in preference order. 94 + * 95 + * @param options search criteria (kid and/or alg) 96 + */ 97 + *list(options?: KeySearchOptions): Generator<PrivateKey> { 98 + const { kid, alg } = options ?? {}; 99 + const algSet = alg == null ? null : new Set(Array.isArray(alg) ? alg : [alg]); 100 + 101 + // sort keys by algorithm preference 102 + const sorted = [...this.keys].sort((a, b) => { 103 + const aIdx = PREFERRED_ALGORITHMS.indexOf(a.alg as (typeof PREFERRED_ALGORITHMS)[number]); 104 + const bIdx = PREFERRED_ALGORITHMS.indexOf(b.alg as (typeof PREFERRED_ALGORITHMS)[number]); 105 + return aIdx - bIdx; 106 + }); 107 + 108 + for (const key of sorted) { 109 + if (kid != null && key.kid !== kid) { 110 + continue; 111 + } 112 + if (algSet != null && !algSet.has(key.alg)) { 113 + continue; 114 + } 115 + yield key; 116 + } 117 + } 118 + 119 + /** 120 + * finds a key for signing, negotiating algorithm with server's supported list. 121 + * 122 + * @param serverAlgs algorithms supported by the server (from metadata) 123 + * @returns key and negotiated algorithm 124 + * @throws if no compatible key is found 125 + */ 126 + findForSigning(serverAlgs?: readonly string[]): { key: PrivateKey; alg: string } { 127 + // if server doesn't specify, default to ES256 per atproto spec 128 + const algs = serverAlgs ?? ['ES256']; 129 + 130 + const key = this.find({ alg: algs }); 131 + if (!key) { 132 + throw new Error(`no key found compatible with server algorithms: ${algs.join(', ')}`); 133 + } 134 + 135 + return { key, alg: key.alg }; 136 + } 137 + 138 + [Symbol.iterator](): Iterator<PrivateKey> { 139 + return this.keys[Symbol.iterator](); 140 + } 141 + }
+47
packages/oauth/node-client/lib/keyset/types.ts
··· 1 + import type { JWK } from 'jose'; 2 + 3 + /** 4 + * signing algorithms supported by AT Protocol OAuth. 5 + * 6 + * @see {@link https://atproto.com/specs/oauth#confidential-client-authentication} 7 + */ 8 + export type SigningAlgorithm = 9 + | 'ES256' 10 + | 'ES384' 11 + | 'ES512' // EC (ES256 is spec minimum) 12 + | 'PS256' 13 + | 'PS384' 14 + | 'PS512' // RSA-PSS 15 + | 'RS256' 16 + | 'RS384' 17 + | 'RS512'; // RSA 18 + 19 + /** 20 + * private key for client authentication via `private_key_jwt`. 21 + */ 22 + export interface PrivateKey { 23 + /** key ID, required for `private_key_jwt` */ 24 + kid: string; 25 + /** signing algorithm */ 26 + alg: SigningAlgorithm; 27 + /** imported key object for signing */ 28 + key: CryptoKey; 29 + /** pre-computed public JWK for JWKS export */ 30 + publicJwk: JWK; 31 + } 32 + 33 + /** options for importing a private key */ 34 + export interface ImportKeyOptions { 35 + /** override or provide key ID */ 36 + kid?: string; 37 + /** override or provide algorithm */ 38 + alg?: SigningAlgorithm; 39 + } 40 + 41 + /** criteria for finding a key in a keyset */ 42 + export interface KeySearchOptions { 43 + /** find by specific key ID */ 44 + kid?: string; 45 + /** find by algorithm (single or array of acceptable algs) */ 46 + alg?: string | readonly string[]; 47 + }
+147
packages/oauth/node-client/lib/oauth-client-auth.ts
··· 1 + import { SignJWT } from 'jose'; 2 + import { nanoid } from 'nanoid'; 3 + 4 + import { CLIENT_ASSERTION_TYPE_JWT_BEARER, FALLBACK_ALG } from './constants.js'; 5 + import type { Keyset } from './keyset/keyset.js'; 6 + import type { PrivateKey } from './keyset/types.js'; 7 + import type { OAuthAuthorizationServerMetadata } from './schemas/oauth-authorization-server-metadata.js'; 8 + 9 + export { CLIENT_ASSERTION_TYPE_JWT_BEARER }; 10 + 11 + /** 12 + * client authentication method. only `private_key_jwt` is supported. 13 + */ 14 + export interface ClientAuthMethod { 15 + method: 'private_key_jwt'; 16 + /** key ID used for signing */ 17 + kid: string; 18 + } 19 + 20 + /** 21 + * client credentials for a token endpoint request. 22 + */ 23 + export interface ClientCredentials { 24 + client_id: string; 25 + client_assertion_type: typeof CLIENT_ASSERTION_TYPE_JWT_BEARER; 26 + client_assertion: string; 27 + } 28 + 29 + /** 30 + * factory function that produces client credentials for each request. 31 + */ 32 + export type ClientCredentialsFactory = () => Promise<ClientCredentials>; 33 + 34 + /** 35 + * negotiates the client authentication method with the authorization server. 36 + * 37 + * @param serverMetadata authorization server metadata 38 + * @param keyset client's private keyset 39 + * @returns negotiated auth method with key ID 40 + * @throws if server doesn't support `private_key_jwt` or no compatible key exists 41 + */ 42 + export const negotiateClientAuth = ( 43 + serverMetadata: OAuthAuthorizationServerMetadata, 44 + keyset: Keyset, 45 + ): ClientAuthMethod => { 46 + const supportedMethods = serverMetadata.token_endpoint_auth_methods_supported; 47 + 48 + // verify server supports private_key_jwt 49 + if (supportedMethods && !supportedMethods.includes('private_key_jwt')) { 50 + throw new Error( 51 + `server does not support "private_key_jwt" authentication. ` + 52 + `supported methods: ${supportedMethods.join(', ')}`, 53 + ); 54 + } 55 + 56 + // get server's supported signing algorithms 57 + const supportedAlgs = serverMetadata.token_endpoint_auth_signing_alg_values_supported ?? [FALLBACK_ALG]; 58 + 59 + // find a compatible key 60 + const key = keyset.find({ alg: supportedAlgs }); 61 + if (!key) { 62 + throw new Error(`no key found compatible with server's signing algorithms: ${supportedAlgs.join(', ')}`); 63 + } 64 + 65 + return { method: 'private_key_jwt', kid: key.kid }; 66 + }; 67 + 68 + export interface CreateClientAssertionFactoryOptions { 69 + /** negotiated auth method (contains kid) */ 70 + authMethod: ClientAuthMethod; 71 + /** authorization server metadata */ 72 + serverMetadata: OAuthAuthorizationServerMetadata; 73 + /** client ID */ 74 + clientId: string; 75 + /** client's private keyset */ 76 + keyset: Keyset; 77 + } 78 + 79 + /** 80 + * creates a factory that produces client credentials (JWT assertions) for token requests. 81 + * 82 + * @param options factory configuration 83 + * @returns async function that creates fresh credentials for each request 84 + * @throws if the key is no longer available in the keyset 85 + */ 86 + export const createClientAssertionFactory = ( 87 + options: CreateClientAssertionFactoryOptions, 88 + ): ClientCredentialsFactory => { 89 + const { authMethod, serverMetadata, clientId, keyset } = options; 90 + 91 + // get server's supported signing algorithms 92 + const supportedAlgs = serverMetadata.token_endpoint_auth_signing_alg_values_supported ?? [FALLBACK_ALG]; 93 + 94 + // find the key matching our negotiated auth method 95 + const key = keyset.find({ kid: authMethod.kid, alg: supportedAlgs }); 96 + if (!key) { 97 + throw new Error(`key "${authMethod.kid}" no longer available or compatible`); 98 + } 99 + 100 + return () => createClientAssertion(key, clientId, serverMetadata.issuer); 101 + }; 102 + 103 + /** 104 + * creates a client assertion JWT per RFC 7523. 105 + * 106 + * @param key private key to sign with 107 + * @param clientId client identifier (used as iss and sub) 108 + * @param audience authorization server issuer (used as aud) 109 + * @returns client credentials for token request 110 + * @see {@link https://www.rfc-editor.org/rfc/rfc7523.html#section-3} 111 + */ 112 + const createClientAssertion = async ( 113 + key: PrivateKey, 114 + clientId: string, 115 + audience: string, 116 + ): Promise<ClientCredentials> => { 117 + const now = Math.floor(Date.now() / 1000); 118 + 119 + const assertion = await new SignJWT({ 120 + // > The JWT MUST contain an "iss" (issuer) claim that contains a 121 + // > unique identifier for the entity that issued the JWT. 122 + iss: clientId, 123 + // > For client authentication, the subject MUST be the 124 + // > "client_id" of the OAuth client. 125 + sub: clientId, 126 + // > The JWT MUST contain an "aud" (audience) claim containing a value 127 + // > that identifies the authorization server as an intended audience. 128 + aud: audience, 129 + // > The JWT MAY contain a "jti" (JWT ID) claim that provides a 130 + // > unique identifier for the token. 131 + jti: nanoid(24), 132 + // > The JWT MAY contain an "iat" (issued at) claim that 133 + // > identifies the time at which the JWT was issued. 134 + iat: now, 135 + // > The JWT MUST contain an "exp" (expiration time) claim that 136 + // > limits the time window during which the JWT can be used. 137 + exp: now + 60, // 1 minute 138 + }) 139 + .setProtectedHeader({ alg: key.alg, kid: key.kid }) 140 + .sign(key.key); 141 + 142 + return { 143 + client_id: clientId, 144 + client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER, 145 + client_assertion: assertion, 146 + }; 147 + };
+462
packages/oauth/node-client/lib/oauth-client.ts
··· 1 + import type { JWK } from 'jose'; 2 + import { nanoid } from 'nanoid'; 3 + 4 + import type { ActorResolver } from '@atcute/identity-resolver'; 5 + import type { ActorIdentifier, Did } from '@atcute/lexicons'; 6 + 7 + import { buildClientMetadata } from './build-client-metadata.js'; 8 + import { FALLBACK_ALG } from './constants.js'; 9 + import type { DpopNonceCache } from './dpop/fetch-dpop.js'; 10 + import { generateDpopKey } from './dpop/generate-key.js'; 11 + import { OAuthCallbackError, TokenRevokedError } from './errors.js'; 12 + import { Keyset } from './keyset/keyset.js'; 13 + import type { PrivateKey } from './keyset/types.js'; 14 + import { OAuthServerAgent } from './oauth-server-agent.js'; 15 + import { OAuthServerFactory } from './oauth-server-factory.js'; 16 + import { OAuthSession } from './oauth-session.js'; 17 + import { generatePkce } from './pkce.js'; 18 + import { 19 + AuthorizationServerMetadataResolver, 20 + type AuthorizationServerMetadataCache, 21 + } from './resolvers/authorization-server-metadata.js'; 22 + import { OAuthResolver } from './resolvers/index.js'; 23 + import { 24 + ProtectedResourceMetadataResolver, 25 + type ProtectedResourceMetadataCache, 26 + } from './resolvers/protected-resource-metadata.js'; 27 + import type { ConfidentialClientMetadata } from './schemas/atcute-confidential-client-metadata.js'; 28 + import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 29 + import type { OAuthResponseMode } from './schemas/oauth-response-mode.js'; 30 + import { SessionGetter, type SessionEventListener } from './session-getter.js'; 31 + import type { SessionStore } from './types/sessions.js'; 32 + import type { StateStore, StoredState } from './types/states.js'; 33 + import type { LockFunction } from './utils/lock.js'; 34 + import { MemoryStore } from './utils/memory-store.js'; 35 + 36 + export interface OAuthClientStores { 37 + /** session store, keyed by DID */ 38 + sessions: SessionStore; 39 + /** authorization state store, keyed by state ID (short-lived) */ 40 + states: StateStore; 41 + /** DPoP nonce cache, keyed by origin (defaults to in-memory) */ 42 + dpopNonces?: DpopNonceCache; 43 + /** AS metadata cache, keyed by issuer (defaults to in-memory) */ 44 + asMetadata?: AuthorizationServerMetadataCache; 45 + /** protected resource metadata cache, keyed by origin (defaults to in-memory) */ 46 + prMetadata?: ProtectedResourceMetadataCache; 47 + } 48 + 49 + export interface OAuthClientOptions { 50 + /** client metadata */ 51 + metadata: ConfidentialClientMetadata; 52 + /** client's signing keys (or an already constructed keyset) */ 53 + keyset: Keyset | PrivateKey[]; 54 + /** identity resolver for DID/handle resolution */ 55 + actorResolver: ActorResolver; 56 + /** storage backends */ 57 + stores: OAuthClientStores; 58 + 59 + /** OAuth response mode for authorization responses */ 60 + responseMode?: OAuthResponseMode; 61 + /** lock function for coordinating token refresh, defaults to in-memory */ 62 + requestLock?: LockFunction; 63 + 64 + /** custom fetch implementation */ 65 + fetch?: typeof globalThis.fetch; 66 + } 67 + 68 + export type AuthorizeTarget = 69 + | { type: 'account'; identifier: ActorIdentifier } 70 + | { type: 'pds'; serviceUrl: string }; 71 + 72 + export interface AuthorizeOptions { 73 + /** target account (handle or DID) or PDS URL */ 74 + target: AuthorizeTarget; 75 + /** requested scopes (defaults to client metadata scope) */ 76 + scope?: string; 77 + /** which redirect_uri to use (defaults to first in metadata.redirect_uris) */ 78 + redirectUri?: string; 79 + /** user-provided state to preserve through flow */ 80 + state?: unknown; 81 + /** OIDC prompt parameter */ 82 + prompt?: 'none' | 'login' | 'consent' | 'select_account'; 83 + /** abort signal */ 84 + signal?: AbortSignal; 85 + } 86 + 87 + export interface AuthorizationResult { 88 + /** URL to redirect user to */ 89 + url: URL; 90 + /** state ID (the OAuth `state` parameter) */ 91 + stateId: string; 92 + } 93 + 94 + export interface CallbackOptions { 95 + /** override redirect_uri for token exchange */ 96 + redirectUri?: string; 97 + } 98 + 99 + export interface CallbackResult { 100 + /** authenticated session */ 101 + session: OAuthSession; 102 + /** user-provided state from authorize() */ 103 + state: unknown; 104 + } 105 + 106 + export interface RestoreOptions { 107 + /** 108 + * 'auto' (default): refresh if token is stale 109 + * true: force refresh even if not stale 110 + * false: don't refresh, return session even if stale 111 + */ 112 + refresh?: boolean | 'auto'; 113 + } 114 + 115 + /** 116 + * OAuth client for AT Protocol confidential clients. 117 + * 118 + * handles authorization flow, session management, and token lifecycle. 119 + */ 120 + export class OAuthClient { 121 + readonly metadata: OAuthClientMetadata; 122 + readonly keyset: Keyset; 123 + 124 + private readonly responseMode: OAuthResponseMode; 125 + private readonly resolver: OAuthResolver; 126 + private readonly serverFactory: OAuthServerFactory; 127 + private readonly sessionGetter: SessionGetter; 128 + private readonly stateStore: StateStore; 129 + private readonly fetch: typeof globalThis.fetch; 130 + 131 + constructor(options: OAuthClientOptions) { 132 + const { stores } = options; 133 + 134 + const keyset = Array.isArray(options.keyset) ? new Keyset(options.keyset) : options.keyset; 135 + 136 + this.metadata = buildClientMetadata(options.metadata, keyset); 137 + this.keyset = keyset; 138 + this.responseMode = options.responseMode ?? 'query'; 139 + this.fetch = options.fetch ?? globalThis.fetch; 140 + 141 + const protectedResourceMetadataCache = 142 + stores.prMetadata ?? 143 + new MemoryStore({ 144 + maxSize: 100, 145 + ttl: 60e3, 146 + ttlAutopurge: true, 147 + }); 148 + 149 + const authorizationServerMetadataCache = 150 + stores.asMetadata ?? 151 + new MemoryStore({ 152 + maxSize: 100, 153 + ttl: 60e3, 154 + ttlAutopurge: true, 155 + }); 156 + 157 + const dpopNoncesCache = 158 + stores.dpopNonces ?? 159 + new MemoryStore({ 160 + maxSize: 100, 161 + ttl: 60e3, 162 + ttlAutopurge: true, 163 + }); 164 + 165 + this.resolver = new OAuthResolver( 166 + options.actorResolver, 167 + new ProtectedResourceMetadataResolver({ 168 + cache: protectedResourceMetadataCache, 169 + fetch: this.fetch, 170 + }), 171 + new AuthorizationServerMetadataResolver({ 172 + cache: authorizationServerMetadataCache, 173 + fetch: this.fetch, 174 + }), 175 + ); 176 + 177 + this.serverFactory = new OAuthServerFactory({ 178 + clientMetadata: this.metadata, 179 + resolver: this.resolver, 180 + keyset: keyset, 181 + dpopNonces: dpopNoncesCache, 182 + fetch: this.fetch, 183 + }); 184 + 185 + this.sessionGetter = new SessionGetter({ 186 + sessionStore: stores.sessions, 187 + serverFactory: this.serverFactory, 188 + requestLock: options.requestLock, 189 + }); 190 + 191 + this.stateStore = stores.states; 192 + } 193 + 194 + /** 195 + * public JWKS for serving at jwks_uri. 196 + */ 197 + get jwks(): { keys: readonly JWK[] } { 198 + return this.keyset.publicJwks; 199 + } 200 + 201 + /** 202 + * adds a listener for session events (updated, deleted). 203 + */ 204 + addEventListener(listener: SessionEventListener): void { 205 + this.sessionGetter.addEventListener(listener); 206 + } 207 + 208 + /** 209 + * removes a session event listener. 210 + */ 211 + removeEventListener(listener: SessionEventListener): void { 212 + this.sessionGetter.removeEventListener(listener); 213 + } 214 + 215 + /** 216 + * starts the authorization flow. 217 + * 218 + * @param options authorization options 219 + * @returns URL to redirect user to and state ID 220 + */ 221 + async authorize(options: AuthorizeOptions): Promise<AuthorizationResult> { 222 + let { target, scope, state: userState, redirectUri, prompt, signal } = options; 223 + 224 + if (scope !== undefined) { 225 + scope = validateRequestedScope(scope, this.metadata.scope!); 226 + } else { 227 + scope = this.metadata.scope!; 228 + } 229 + 230 + if (redirectUri !== undefined) { 231 + if (!this.metadata.redirect_uris.includes(redirectUri)) { 232 + throw new TypeError(`specified redirect_uri not in client metadata: ${redirectUri}`); 233 + } 234 + } else { 235 + redirectUri = this.metadata.redirect_uris[0]; 236 + } 237 + 238 + // resolve target to AS metadata 239 + let resolved; 240 + if (target.type === 'account') { 241 + resolved = await this.resolver.resolveFromIdentity(target.identifier, { signal }); 242 + } else { 243 + resolved = await this.resolver.resolveFromService(target.serviceUrl, { signal }); 244 + } 245 + 246 + const { identity, metadata } = resolved; 247 + 248 + signal?.throwIfAborted(); 249 + 250 + // generate PKCE and DPoP key 251 + const pkce = await generatePkce(); 252 + const dpopKey = await generateDpopKey(metadata.dpop_signing_alg_values_supported ?? [FALLBACK_ALG]); 253 + 254 + // create server agent and negotiate auth method 255 + const server = this.serverFactory.fromMetadataNewSession(metadata, dpopKey); 256 + 257 + // generate state 258 + const stateId = nanoid(24); 259 + 260 + // store authorization state 261 + const storedState: StoredState = { 262 + dpopKey, 263 + authMethod: server.authMethod, 264 + pkceVerifier: pkce.verifier, 265 + issuer: metadata.issuer, 266 + redirectUri, 267 + sub: identity?.did, 268 + userState, 269 + expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes 270 + }; 271 + 272 + await this.stateStore.set(stateId, storedState); 273 + 274 + // build PAR request 275 + const parParams: Record<string, string> = { 276 + client_id: this.metadata.client_id!, 277 + redirect_uri: redirectUri, 278 + response_type: 'code', 279 + response_mode: this.responseMode, 280 + scope: scope, 281 + state: stateId, 282 + code_challenge: pkce.challenge, 283 + code_challenge_method: pkce.method, 284 + }; 285 + 286 + // use handle for login_hint, fall back to DID if handle is invalid 287 + if (identity) { 288 + parParams.login_hint = identity.handle !== 'handle.invalid' ? identity.handle : identity.did; 289 + } 290 + if (prompt) { 291 + parParams.prompt = prompt; 292 + } 293 + 294 + // push authorization request 295 + const parResponse = await server.pushAuthorizationRequest(parParams); 296 + 297 + // build authorization URL 298 + const authUrl = new URL(metadata.authorization_endpoint); 299 + authUrl.searchParams.set('client_id', this.metadata.client_id!); 300 + authUrl.searchParams.set('request_uri', parResponse.request_uri); 301 + 302 + return { url: authUrl, stateId }; 303 + } 304 + 305 + /** 306 + * handles the OAuth callback. 307 + * 308 + * @param params URL search params from callback 309 + * @param options callback options 310 + * @returns session and user state 311 + */ 312 + async callback(params: URLSearchParams, options?: CallbackOptions): Promise<CallbackResult> { 313 + const stateParam = params.get('state'); 314 + const errorParam = params.get('error'); 315 + const codeParam = params.get('code'); 316 + const issParam = params.get('iss'); 317 + 318 + // validate state 319 + if (!stateParam) { 320 + throw new OAuthCallbackError('invalid_request', 'missing state parameter'); 321 + } 322 + 323 + const storedState = await this.stateStore.get(stateParam); 324 + if (!storedState) { 325 + throw new OAuthCallbackError('invalid_request', 'unknown state', stateParam); 326 + } 327 + 328 + // delete state immediately to prevent replay 329 + await this.stateStore.delete(stateParam); 330 + 331 + // check for error response 332 + if (errorParam) { 333 + throw new OAuthCallbackError(errorParam, params.get('error_description') ?? undefined, stateParam); 334 + } 335 + 336 + // validate code 337 + if (!codeParam) { 338 + throw new OAuthCallbackError('invalid_request', 'missing code parameter', stateParam); 339 + } 340 + 341 + // create server agent from stored state 342 + const server = await this.serverFactory.fromIssuer( 343 + storedState.issuer, 344 + storedState.authMethod, 345 + storedState.dpopKey, 346 + ); 347 + 348 + // validate iss parameter (RFC 9207 - mix-up attack prevention) 349 + if (issParam != null) { 350 + if (server.issuer !== issParam) { 351 + throw new OAuthCallbackError('invalid_request', 'issuer mismatch', stateParam); 352 + } 353 + } else if (server.serverMetadata.authorization_response_iss_parameter_supported) { 354 + throw new OAuthCallbackError('invalid_request', 'missing iss parameter', stateParam); 355 + } 356 + 357 + // exchange code for tokens 358 + const redirectUri = options?.redirectUri ?? storedState.redirectUri; 359 + const tokenSet = await server.exchangeCode(codeParam, storedState.pkceVerifier, redirectUri); 360 + 361 + // verify expected sub if we resolved one 362 + if (storedState.sub && tokenSet.sub !== storedState.sub) { 363 + await server.revoke(tokenSet.access_token); 364 + throw new OAuthCallbackError('invalid_request', 'sub mismatch', stateParam); 365 + } 366 + 367 + // store session 368 + try { 369 + await this.sessionGetter.setStored(tokenSet.sub, { 370 + dpopKey: storedState.dpopKey, 371 + authMethod: server.authMethod, 372 + tokenSet, 373 + }); 374 + } catch (err) { 375 + await server.revoke(tokenSet.access_token); 376 + throw err; 377 + } 378 + 379 + const session = this.createSession(server, tokenSet.sub); 380 + 381 + return { session, state: storedState.userState }; 382 + } 383 + 384 + /** 385 + * restores an existing session. 386 + * 387 + * @param sub user's DID 388 + * @param options restore options 389 + * @returns authenticated session 390 + */ 391 + async restore(sub: Did, options?: RestoreOptions): Promise<OAuthSession> { 392 + const refresh = options?.refresh ?? 'auto'; 393 + 394 + const { dpopKey, authMethod, tokenSet } = await this.sessionGetter.getSession(sub, refresh); 395 + 396 + const server = await this.serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey, { 397 + noCache: refresh === true, 398 + }); 399 + 400 + return this.createSession(server, sub); 401 + } 402 + 403 + /** 404 + * revokes and deletes a session. 405 + * 406 + * @param sub user's DID 407 + */ 408 + async revoke(sub: Did): Promise<void> { 409 + const { dpopKey, authMethod, tokenSet } = await this.sessionGetter.getSession(sub, false); 410 + 411 + try { 412 + const server = await this.serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey); 413 + await server.revoke(tokenSet.access_token); 414 + } finally { 415 + await this.sessionGetter.deleteStored(sub, new TokenRevokedError(sub)); 416 + } 417 + } 418 + 419 + private createSession(server: OAuthServerAgent, sub: Did): OAuthSession { 420 + return new OAuthSession(server, sub, this.sessionGetter, this.fetch); 421 + } 422 + } 423 + 424 + const parseScope = (scope: string): string[] => { 425 + return scope.trim().split(/\s+/); 426 + }; 427 + 428 + const validateRequestedScope = (requested: string, allowed: string): string => { 429 + const requestedParts = parseScope(requested); 430 + if (requestedParts.length === 0) { 431 + throw new TypeError(`missing scope`); 432 + } 433 + 434 + for (let i = 0, il = requestedParts.length; i < il; i++) { 435 + const aka = requestedParts[i]; 436 + 437 + for (let j = 0; j < i; j++) { 438 + if (aka === requestedParts[j]) { 439 + throw new TypeError(`duplicate "${aka}" scope`); 440 + } 441 + } 442 + } 443 + 444 + const allowedParts = parseScope(allowed); 445 + for (let i = 0, il = requestedParts.length; i < il; i++) { 446 + const scope = requestedParts[i]; 447 + 448 + let found = false; 449 + for (let j = 0, jl = allowedParts.length; j < jl; j++) { 450 + if (scope === allowedParts[j]) { 451 + found = true; 452 + break; 453 + } 454 + } 455 + 456 + if (!found) { 457 + throw new Error(`requested "${scope}" scope is not within client metadata's scope`); 458 + } 459 + } 460 + 461 + return requestedParts.join(' '); 462 + };
+307
packages/oauth/node-client/lib/oauth-server-agent.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + 3 + import type { Did } from '@atcute/lexicons'; 4 + 5 + import { generateDpopKey } from './dpop/generate-key.js'; 6 + import { OAuthResponseError, TokenRefreshError } from './errors.js'; 7 + import { generatePrivateKey } from './keyset/import-key.js'; 8 + import { Keyset } from './keyset/keyset.js'; 9 + import { OAuthServerAgent, type OAuthServerAgentOptions } from './oauth-server-agent.js'; 10 + import type { OAuthResolver } from './resolvers/index.js'; 11 + import type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js'; 12 + import { MemoryStore } from './utils/memory-store.js'; 13 + 14 + const createMockMetadata = (): AtprotoAuthorizationServerMetadata => ({ 15 + issuer: 'https://auth.example.com', 16 + authorization_endpoint: 'https://auth.example.com/oauth/authorize', 17 + token_endpoint: 'https://auth.example.com/oauth/token', 18 + pushed_authorization_request_endpoint: 'https://auth.example.com/oauth/par', 19 + revocation_endpoint: 'https://auth.example.com/oauth/revoke', 20 + dpop_signing_alg_values_supported: ['ES256'], 21 + scopes_supported: ['atproto'], 22 + response_types_supported: ['code'], 23 + grant_types_supported: ['authorization_code', 'refresh_token'], 24 + code_challenge_methods_supported: ['S256'], 25 + token_endpoint_auth_methods_supported: ['private_key_jwt'], 26 + token_endpoint_auth_signing_alg_values_supported: ['ES256'], 27 + authorization_response_iss_parameter_supported: true, 28 + require_pushed_authorization_requests: true, 29 + client_id_metadata_document_supported: true, 30 + }); 31 + 32 + const createMockResponse = (status: number, body?: unknown, headers?: Record<string, string>): Response => { 33 + return new Response(body ? JSON.stringify(body) : null, { 34 + status, 35 + headers: { 36 + 'Content-Type': 'application/json', 37 + ...headers, 38 + }, 39 + }); 40 + }; 41 + 42 + const createMockOAuthResolver = (overrides?: { issuer?: string; pds?: string }): OAuthResolver => 43 + ({ 44 + resolveFromIdentity: vi.fn().mockResolvedValue({ 45 + metadata: { issuer: overrides?.issuer ?? 'https://auth.example.com' }, 46 + identity: { pds: overrides?.pds ?? 'https://pds.example.com' }, 47 + }), 48 + }) as unknown as OAuthResolver; 49 + 50 + const createServerAgent = async ( 51 + options?: Partial<OAuthServerAgentOptions> & { mockFetch?: typeof fetch }, 52 + ): Promise<OAuthServerAgent> => { 53 + const privateKey = await generatePrivateKey('dpop-key', 'ES256'); 54 + const dpopKey = await generateDpopKey(); 55 + const keyset = new Keyset([privateKey]); 56 + 57 + return new OAuthServerAgent({ 58 + authMethod: { method: 'private_key_jwt', kid: 'dpop-key' }, 59 + dpopKey, 60 + serverMetadata: options?.serverMetadata ?? createMockMetadata(), 61 + clientMetadata: { 62 + client_id: 'https://app.example.com/client-metadata.json', 63 + client_name: 'Test App', 64 + redirect_uris: ['https://app.example.com/callback'], 65 + grant_types: ['authorization_code', 'refresh_token'], 66 + response_types: ['code'], 67 + scope: 'atproto', 68 + application_type: 'web', 69 + dpop_bound_access_tokens: true, 70 + token_endpoint_auth_method: 'private_key_jwt', 71 + token_endpoint_auth_signing_alg: 'ES256', 72 + }, 73 + dpopNonces: new MemoryStore({}), 74 + oauthResolver: options?.oauthResolver ?? createMockOAuthResolver(), 75 + keyset, 76 + fetch: options?.mockFetch, 77 + }); 78 + }; 79 + 80 + describe('OAuthServerAgent', () => { 81 + // valid DID format for testing (did:plc uses base32 encoding) 82 + const TEST_DID = 'did:plc:ewvi7nxzyoun6zhxrhs64oiz'; 83 + 84 + describe('exchangeCode', () => { 85 + it('should exchange code for tokens', async () => { 86 + const mockFetch = vi.fn().mockResolvedValue( 87 + createMockResponse(200, { 88 + access_token: 'access-123', 89 + refresh_token: 'refresh-123', 90 + token_type: 'DPoP', 91 + expires_in: 3600, 92 + scope: 'atproto', 93 + sub: TEST_DID, 94 + }), 95 + ); 96 + 97 + const agent = await createServerAgent({ mockFetch }); 98 + 99 + const result = await agent.exchangeCode('auth-code', 'verifier', 'https://app.example.com/callback'); 100 + 101 + expect(result.access_token).toBe('access-123'); 102 + expect(result.refresh_token).toBe('refresh-123'); 103 + expect(result.sub).toBe(TEST_DID); 104 + expect(result.iss).toBe('https://auth.example.com'); 105 + expect(mockFetch).toHaveBeenCalledTimes(1); 106 + }); 107 + 108 + it('should verify issuer matches identity resolution', async () => { 109 + const mockFetch = vi.fn().mockResolvedValue( 110 + createMockResponse(200, { 111 + access_token: 'access-123', 112 + refresh_token: 'refresh-123', 113 + token_type: 'DPoP', 114 + scope: 'atproto', 115 + sub: TEST_DID, 116 + }), 117 + ); 118 + 119 + const oauthResolver = createMockOAuthResolver({ 120 + issuer: 'https://different-auth.example.com', 121 + }); 122 + 123 + const agent = await createServerAgent({ mockFetch, oauthResolver }); 124 + 125 + await expect( 126 + agent.exchangeCode('auth-code', 'verifier', 'https://app.example.com/callback'), 127 + ).rejects.toThrow('issuer mismatch'); 128 + 129 + // should have attempted to revoke 130 + expect(mockFetch).toHaveBeenCalledTimes(2); 131 + }); 132 + 133 + it('should throw on error response', async () => { 134 + const mockFetch = vi.fn().mockResolvedValue( 135 + createMockResponse(400, { 136 + error: 'invalid_grant', 137 + error_description: 'code expired', 138 + }), 139 + ); 140 + 141 + const agent = await createServerAgent({ mockFetch }); 142 + 143 + await expect( 144 + agent.exchangeCode('expired-code', 'verifier', 'https://app.example.com/callback'), 145 + ).rejects.toThrow(OAuthResponseError); 146 + }); 147 + }); 148 + 149 + describe('refresh', () => { 150 + it('should refresh token set', async () => { 151 + const mockFetch = vi.fn().mockResolvedValue( 152 + createMockResponse(200, { 153 + access_token: 'new-access-123', 154 + refresh_token: 'new-refresh-123', 155 + token_type: 'DPoP', 156 + expires_in: 3600, 157 + scope: 'atproto', 158 + sub: TEST_DID, 159 + }), 160 + ); 161 + 162 + const agent = await createServerAgent({ mockFetch }); 163 + 164 + const result = await agent.refresh({ 165 + iss: 'https://auth.example.com', 166 + sub: TEST_DID as Did, 167 + aud: 'https://pds.example.com', 168 + scope: 'atproto', 169 + access_token: 'old-access', 170 + refresh_token: 'old-refresh', 171 + token_type: 'DPoP', 172 + }); 173 + 174 + expect(result.access_token).toBe('new-access-123'); 175 + expect(result.refresh_token).toBe('new-refresh-123'); 176 + }); 177 + 178 + it('should throw TokenRefreshError if no refresh token', async () => { 179 + const agent = await createServerAgent({ mockFetch: vi.fn() }); 180 + 181 + await expect( 182 + agent.refresh({ 183 + iss: 'https://auth.example.com', 184 + sub: TEST_DID as Did, 185 + aud: 'https://pds.example.com', 186 + scope: 'atproto', 187 + access_token: 'access', 188 + token_type: 'DPoP', 189 + // no refresh_token 190 + }), 191 + ).rejects.toThrow(TokenRefreshError); 192 + }); 193 + 194 + it('should verify issuer before refreshing', async () => { 195 + const oauthResolver = createMockOAuthResolver({ 196 + issuer: 'https://different-auth.example.com', 197 + }); 198 + 199 + const mockFetch = vi.fn(); 200 + const agent = await createServerAgent({ mockFetch, oauthResolver }); 201 + 202 + await expect( 203 + agent.refresh({ 204 + iss: 'https://auth.example.com', 205 + sub: TEST_DID as Did, 206 + aud: 'https://pds.example.com', 207 + scope: 'atproto', 208 + access_token: 'access', 209 + refresh_token: 'refresh', 210 + token_type: 'DPoP', 211 + }), 212 + ).rejects.toThrow('issuer mismatch'); 213 + 214 + // should NOT have made token request since issuer check failed first 215 + expect(mockFetch).not.toHaveBeenCalled(); 216 + }); 217 + }); 218 + 219 + describe('pushAuthorizationRequest', () => { 220 + it('should send PAR and return request_uri', async () => { 221 + const mockFetch = vi.fn().mockResolvedValue( 222 + createMockResponse(200, { 223 + request_uri: 'urn:ietf:params:oauth:request_uri:abc123', 224 + expires_in: 60, 225 + }), 226 + ); 227 + 228 + const agent = await createServerAgent({ mockFetch }); 229 + 230 + const result = await agent.pushAuthorizationRequest({ 231 + response_type: 'code', 232 + redirect_uri: 'https://app.example.com/callback', 233 + scope: 'atproto', 234 + code_challenge: 'challenge', 235 + code_challenge_method: 'S256', 236 + state: 'state123', 237 + }); 238 + 239 + expect(result.request_uri).toBe('urn:ietf:params:oauth:request_uri:abc123'); 240 + expect(mockFetch).toHaveBeenCalledTimes(1); 241 + }); 242 + 243 + it('should throw on PAR error', async () => { 244 + const mockFetch = vi.fn().mockResolvedValue( 245 + createMockResponse(400, { 246 + error: 'invalid_request', 247 + error_description: 'bad redirect_uri', 248 + }), 249 + ); 250 + 251 + const agent = await createServerAgent({ mockFetch }); 252 + 253 + await expect( 254 + agent.pushAuthorizationRequest({ 255 + response_type: 'code', 256 + redirect_uri: 'https://malicious.example.com/callback', 257 + scope: 'atproto', 258 + }), 259 + ).rejects.toThrow(OAuthResponseError); 260 + }); 261 + }); 262 + 263 + describe('revoke', () => { 264 + it('should revoke token', async () => { 265 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200)); 266 + 267 + const agent = await createServerAgent({ mockFetch }); 268 + 269 + // should not throw 270 + await agent.revoke('token-to-revoke'); 271 + 272 + expect(mockFetch).toHaveBeenCalledTimes(1); 273 + }); 274 + 275 + it('should silently ignore revocation errors', async () => { 276 + const mockFetch = vi.fn().mockRejectedValue(new Error('network error')); 277 + 278 + const agent = await createServerAgent({ mockFetch }); 279 + 280 + // should not throw 281 + await agent.revoke('token-to-revoke'); 282 + }); 283 + 284 + it('should skip revocation if no endpoint', async () => { 285 + const mockFetch = vi.fn(); 286 + const metadata = createMockMetadata(); 287 + delete (metadata as Record<string, unknown>).revocation_endpoint; 288 + 289 + const agent = await createServerAgent({ 290 + mockFetch, 291 + serverMetadata: metadata, 292 + }); 293 + 294 + await agent.revoke('token'); 295 + 296 + expect(mockFetch).not.toHaveBeenCalled(); 297 + }); 298 + }); 299 + 300 + describe('issuer property', () => { 301 + it('should return server issuer', async () => { 302 + const agent = await createServerAgent({}); 303 + 304 + expect(agent.issuer).toBe('https://auth.example.com'); 305 + }); 306 + }); 307 + });
+293
packages/oauth/node-client/lib/oauth-server-agent.ts
··· 1 + import type { JWK } from 'jose'; 2 + 3 + import type { Did } from '@atcute/lexicons'; 4 + import { parseResponseAsJson, pipe, validateJsonWith } from '@atcute/util-fetch'; 5 + 6 + import { JSON_MIME, PAR_RESPONSE_MAX_SIZE, TOKEN_RESPONSE_MAX_SIZE } from './constants.js'; 7 + import { createDpopFetch } from './dpop/fetch-dpop.js'; 8 + import { OAuthResponseError, TokenRefreshError } from './errors.js'; 9 + import { Keyset } from './keyset/keyset.js'; 10 + import { 11 + createClientAssertionFactory, 12 + type ClientAuthMethod, 13 + type ClientCredentialsFactory, 14 + } from './oauth-client-auth.js'; 15 + import { OAuthResolver } from './resolvers/index.js'; 16 + import type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js'; 17 + import { 18 + atprotoOAuthTokenResponseSchema, 19 + type AtprotoOAuthTokenResponse, 20 + } from './schemas/atproto-oauth-token-response.js'; 21 + import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 22 + import { oauthParResponseSchema, type OAuthParResponse } from './schemas/oauth-par-response.js'; 23 + import type { TokenSet } from './types/token-set.js'; 24 + import type { Store } from './utils/store.js'; 25 + 26 + const processTokenResponse = pipe( 27 + parseResponseAsJson(JSON_MIME, TOKEN_RESPONSE_MAX_SIZE), 28 + validateJsonWith(atprotoOAuthTokenResponseSchema, { mode: 'passthrough' }), 29 + ); 30 + 31 + const processParResponse = pipe( 32 + parseResponseAsJson(JSON_MIME, PAR_RESPONSE_MAX_SIZE), 33 + validateJsonWith(oauthParResponseSchema, { mode: 'passthrough' }), 34 + ); 35 + 36 + export type DpopNonceCache = Store<string, string>; 37 + 38 + export interface OAuthServerAgentOptions { 39 + /** negotiated client authentication method */ 40 + authMethod: ClientAuthMethod; 41 + /** DPoP private key */ 42 + dpopKey: JWK; 43 + /** authorization server metadata */ 44 + serverMetadata: AtprotoAuthorizationServerMetadata; 45 + /** client metadata */ 46 + clientMetadata: OAuthClientMetadata; 47 + /** DPoP nonce cache, keyed by origin */ 48 + dpopNonces: DpopNonceCache; 49 + /** OAuth resolver for identity verification */ 50 + oauthResolver: OAuthResolver; 51 + /** client's private keyset */ 52 + keyset: Keyset; 53 + /** custom fetch implementation */ 54 + fetch?: typeof globalThis.fetch; 55 + } 56 + 57 + /** 58 + * handles OAuth operations with an authorization server. 59 + * 60 + * manages token exchange, refresh, and revocation with DPoP support. 61 + */ 62 + export class OAuthServerAgent { 63 + readonly authMethod: ClientAuthMethod; 64 + readonly dpopKey: JWK; 65 + readonly serverMetadata: AtprotoAuthorizationServerMetadata; 66 + readonly clientMetadata: OAuthClientMetadata; 67 + readonly oauthResolver: OAuthResolver; 68 + readonly keyset: Keyset; 69 + readonly dpopNonces: DpopNonceCache; 70 + 71 + private readonly dpopFetch: typeof globalThis.fetch; 72 + private readonly clientCredentialsFactory: ClientCredentialsFactory; 73 + 74 + constructor(options: OAuthServerAgentOptions) { 75 + this.authMethod = options.authMethod; 76 + this.dpopKey = options.dpopKey; 77 + this.serverMetadata = options.serverMetadata; 78 + this.clientMetadata = options.clientMetadata; 79 + this.oauthResolver = options.oauthResolver; 80 + this.keyset = options.keyset; 81 + this.dpopNonces = options.dpopNonces; 82 + 83 + this.clientCredentialsFactory = createClientAssertionFactory({ 84 + authMethod: options.authMethod, 85 + serverMetadata: options.serverMetadata, 86 + clientId: options.clientMetadata.client_id!, 87 + keyset: options.keyset, 88 + }); 89 + 90 + this.dpopFetch = createDpopFetch({ 91 + key: options.dpopKey, 92 + nonces: options.dpopNonces, 93 + supportedAlgs: options.serverMetadata.dpop_signing_alg_values_supported, 94 + isAuthServer: true, 95 + fetch: options.fetch, 96 + }); 97 + } 98 + 99 + get issuer(): string { 100 + return this.serverMetadata.issuer; 101 + } 102 + 103 + /** 104 + * revokes a token (access or refresh). 105 + * 106 + * @param token token to revoke 107 + */ 108 + async revoke(token: string): Promise<void> { 109 + const endpoint = this.serverMetadata.revocation_endpoint; 110 + if (!endpoint) { 111 + return; 112 + } 113 + 114 + try { 115 + await this.request(endpoint, { token }); 116 + } catch { 117 + // ignore revocation errors 118 + } 119 + } 120 + 121 + /** 122 + * exchanges an authorization code for tokens. 123 + * 124 + * @param code authorization code from callback 125 + * @param codeVerifier PKCE code verifier 126 + * @param redirectUri redirect URI used in authorization request 127 + * @returns token set with verified subject 128 + */ 129 + async exchangeCode(code: string, codeVerifier: string, redirectUri: string): Promise<TokenSet> { 130 + const now = Date.now(); 131 + 132 + const tokenResponse = await this.requestToken({ 133 + grant_type: 'authorization_code', 134 + redirect_uri: redirectUri, 135 + code, 136 + code_verifier: codeVerifier, 137 + }); 138 + 139 + try { 140 + // IMPORTANT: verify that the subject's issuer matches this server 141 + const aud = await this.verifyIssuer(tokenResponse.sub); 142 + 143 + return { 144 + iss: this.issuer, 145 + sub: tokenResponse.sub, 146 + aud, 147 + scope: tokenResponse.scope, 148 + access_token: tokenResponse.access_token, 149 + refresh_token: tokenResponse.refresh_token, 150 + token_type: tokenResponse.token_type, 151 + expires_at: 152 + typeof tokenResponse.expires_in === 'number' ? now + tokenResponse.expires_in * 1000 : undefined, 153 + }; 154 + } catch (err) { 155 + // revoke on verification failure 156 + await this.revoke(tokenResponse.access_token); 157 + throw err; 158 + } 159 + } 160 + 161 + /** 162 + * refreshes an existing token set. 163 + * 164 + * @param tokenSet current token set 165 + * @returns new token set 166 + * @throws {TokenRefreshError} if no refresh token or refresh fails 167 + */ 168 + async refresh(tokenSet: TokenSet): Promise<TokenSet> { 169 + if (!tokenSet.refresh_token) { 170 + throw new TokenRefreshError(tokenSet.sub, 'no refresh token available'); 171 + } 172 + 173 + // verify issuer BEFORE refresh to avoid unnecessary requests 174 + const aud = await this.verifyIssuer(tokenSet.sub); 175 + 176 + const now = Date.now(); 177 + 178 + const tokenResponse = await this.requestToken({ 179 + grant_type: 'refresh_token', 180 + refresh_token: tokenSet.refresh_token, 181 + }); 182 + 183 + return { 184 + iss: this.issuer, 185 + sub: tokenSet.sub, 186 + aud, 187 + scope: tokenResponse.scope, 188 + access_token: tokenResponse.access_token, 189 + refresh_token: tokenResponse.refresh_token, 190 + token_type: tokenResponse.token_type, 191 + expires_at: 192 + typeof tokenResponse.expires_in === 'number' ? now + tokenResponse.expires_in * 1000 : undefined, 193 + }; 194 + } 195 + 196 + /** 197 + * sends a pushed authorization request (PAR). 198 + * 199 + * @param params authorization request parameters 200 + * @returns PAR response with request_uri 201 + */ 202 + async pushAuthorizationRequest(params: Record<string, string>): Promise<OAuthParResponse> { 203 + const endpoint = this.serverMetadata.pushed_authorization_request_endpoint; 204 + const { json } = await this.request(endpoint, params, processParResponse); 205 + return json; 206 + } 207 + 208 + /** 209 + * verifies that the subject's authorization server matches this one. 210 + * 211 + * this is a critical security check per atproto OAuth spec. 212 + * 213 + * @param sub user's DID 214 + * @returns user's PDS URL 215 + * @throws if issuer doesn't match 216 + */ 217 + private async verifyIssuer(sub: Did): Promise<string> { 218 + const resolved = await this.oauthResolver.resolveFromIdentity(sub, { 219 + noCache: true, 220 + signal: AbortSignal.timeout(10_000), 221 + }); 222 + 223 + if (this.issuer !== resolved.metadata.issuer) { 224 + throw new TypeError( 225 + `issuer mismatch: token issued by ${this.issuer}, but identity resolves to ${resolved.metadata.issuer}`, 226 + ); 227 + } 228 + 229 + return resolved.identity.pds; 230 + } 231 + 232 + /** 233 + * makes a token endpoint request. 234 + */ 235 + private async requestToken(params: Record<string, string | undefined>): Promise<AtprotoOAuthTokenResponse> { 236 + const endpoint = this.serverMetadata.token_endpoint; 237 + const { json } = await this.request(endpoint, params, processTokenResponse); 238 + return json; 239 + } 240 + 241 + /** 242 + * makes a request to an authorization server endpoint. 243 + */ 244 + private async request<T>( 245 + endpoint: string, 246 + params: Record<string, string | undefined>, 247 + processor?: (response: Response) => Promise<{ response: Response; json: T }>, 248 + ): Promise<{ response: Response; json: T }> { 249 + const credentials = await this.clientCredentialsFactory(); 250 + 251 + const body = new URLSearchParams(); 252 + // add request params 253 + for (const [key, value] of Object.entries(params)) { 254 + if (value !== undefined) { 255 + body.set(key, value); 256 + } 257 + } 258 + // add client credentials 259 + body.set('client_id', credentials.client_id); 260 + body.set('client_assertion_type', credentials.client_assertion_type); 261 + body.set('client_assertion', credentials.client_assertion); 262 + 263 + const response = await this.dpopFetch(endpoint, { 264 + method: 'POST', 265 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 266 + body: body.toString(), 267 + }); 268 + 269 + if (!response.ok) { 270 + let error = 'unknown_error'; 271 + let errorDescription: string | undefined; 272 + 273 + try { 274 + const json = await response.clone().json(); 275 + if (typeof json === 'object' && json !== null) { 276 + error = json.error ?? error; 277 + errorDescription = json.error_description; 278 + } 279 + } catch { 280 + // ignore parse errors 281 + } 282 + 283 + throw new OAuthResponseError(response, error, errorDescription); 284 + } 285 + 286 + if (processor) { 287 + return processor(response); 288 + } 289 + 290 + // for endpoints without specific processing (e.g., revocation) 291 + return { response, json: undefined as T }; 292 + } 293 + }
+101
packages/oauth/node-client/lib/oauth-server-factory.ts
··· 1 + import type { JWK } from 'jose'; 2 + 3 + import { Keyset } from './keyset/keyset.js'; 4 + import { type ClientAuthMethod, negotiateClientAuth } from './oauth-client-auth.js'; 5 + import { OAuthServerAgent } from './oauth-server-agent.js'; 6 + import { OAuthResolver } from './resolvers/index.js'; 7 + import type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js'; 8 + import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 9 + import type { Store } from './utils/store.js'; 10 + 11 + export interface OAuthServerFactoryOptions { 12 + /** client metadata */ 13 + clientMetadata: OAuthClientMetadata; 14 + /** OAuth resolver for metadata discovery */ 15 + resolver: OAuthResolver; 16 + /** client's private keyset */ 17 + keyset: Keyset; 18 + /** DPoP nonce cache, keyed by origin */ 19 + dpopNonces: Store<string, string>; 20 + /** custom fetch implementation */ 21 + fetch?: typeof globalThis.fetch; 22 + } 23 + 24 + /** 25 + * factory for creating OAuthServerAgent instances. 26 + * 27 + * used to recreate agents from stored session data for token refresh. 28 + */ 29 + export class OAuthServerFactory { 30 + readonly clientMetadata: OAuthClientMetadata; 31 + readonly resolver: OAuthResolver; 32 + readonly keyset: Keyset; 33 + readonly dpopNonces: Store<string, string>; 34 + readonly fetch?: typeof globalThis.fetch; 35 + 36 + constructor(options: OAuthServerFactoryOptions) { 37 + this.clientMetadata = options.clientMetadata; 38 + this.resolver = options.resolver; 39 + this.keyset = options.keyset; 40 + this.dpopNonces = options.dpopNonces; 41 + this.fetch = options.fetch; 42 + } 43 + 44 + /** 45 + * creates an OAuthServerAgent from an issuer and stored session data. 46 + * 47 + * @param issuer authorization server issuer 48 + * @param authMethod client authentication method from stored session 49 + * @param dpopKey DPoP key from stored session 50 + * @param options fetch options 51 + * @returns configured OAuthServerAgent 52 + */ 53 + async fromIssuer( 54 + issuer: string, 55 + authMethod: ClientAuthMethod, 56 + dpopKey: JWK, 57 + options?: { signal?: AbortSignal; noCache?: boolean }, 58 + ): Promise<OAuthServerAgent> { 59 + const serverMetadata = await this.resolver.authorizationServerResolver.resolve(issuer, options); 60 + return this.fromMetadata(serverMetadata, authMethod, dpopKey); 61 + } 62 + 63 + /** 64 + * creates an OAuthServerAgent from resolved metadata. 65 + * 66 + * @param serverMetadata authorization server metadata 67 + * @param authMethod client authentication method 68 + * @param dpopKey DPoP private key 69 + * @returns configured OAuthServerAgent 70 + */ 71 + fromMetadata( 72 + serverMetadata: AtprotoAuthorizationServerMetadata, 73 + authMethod: ClientAuthMethod, 74 + dpopKey: JWK, 75 + ): OAuthServerAgent { 76 + return new OAuthServerAgent({ 77 + authMethod, 78 + dpopKey, 79 + serverMetadata, 80 + clientMetadata: this.clientMetadata, 81 + dpopNonces: this.dpopNonces, 82 + oauthResolver: this.resolver, 83 + keyset: this.keyset, 84 + fetch: this.fetch, 85 + }); 86 + } 87 + 88 + /** 89 + * creates an OAuthServerAgent for a new authorization flow. 90 + * 91 + * negotiates the auth method with the server. 92 + * 93 + * @param serverMetadata authorization server metadata 94 + * @param dpopKey DPoP private key 95 + * @returns configured OAuthServerAgent 96 + */ 97 + fromMetadataNewSession(serverMetadata: AtprotoAuthorizationServerMetadata, dpopKey: JWK): OAuthServerAgent { 98 + const authMethod = negotiateClientAuth(serverMetadata, this.keyset); 99 + return this.fromMetadata(serverMetadata, authMethod, dpopKey); 100 + } 101 + }
+185
packages/oauth/node-client/lib/oauth-session.ts
··· 1 + import type { FetchHandlerObject } from '@atcute/client'; 2 + import type { Did } from '@atcute/lexicons'; 3 + 4 + import { createDpopFetch } from './dpop/fetch-dpop.js'; 5 + import { TokenInvalidError, TokenRevokedError } from './errors.js'; 6 + import type { OAuthServerAgent } from './oauth-server-agent.js'; 7 + import type { AtprotoOAuthScope } from './schemas/atproto-oauth-scope.js'; 8 + import type { SessionGetter } from './session-getter.js'; 9 + import type { TokenSet } from './types/token-set.js'; 10 + 11 + /** 12 + * token information for external use. 13 + */ 14 + export interface TokenInfo { 15 + /** token expiration time */ 16 + expiresAt?: Date; 17 + /** whether the token is expired */ 18 + expired?: boolean; 19 + /** granted scope */ 20 + scope: AtprotoOAuthScope; 21 + /** authorization server issuer */ 22 + iss: string; 23 + /** resource server (PDS) URL */ 24 + aud: string; 25 + /** user's DID */ 26 + sub: Did; 27 + } 28 + 29 + /** 30 + * represents an authenticated user session. 31 + * 32 + * provides methods for making authenticated requests to the user's PDS 33 + * and managing the session lifecycle. 34 + */ 35 + export class OAuthSession implements FetchHandlerObject { 36 + private readonly dpopFetch: typeof globalThis.fetch; 37 + 38 + constructor( 39 + /** server agent for this session's AS */ 40 + readonly server: OAuthServerAgent, 41 + /** user's DID */ 42 + readonly sub: Did, 43 + /** session getter for token management */ 44 + private readonly sessionGetter: SessionGetter, 45 + fetch: typeof globalThis.fetch = globalThis.fetch, 46 + ) { 47 + this.dpopFetch = createDpopFetch({ 48 + key: server.dpopKey, 49 + nonces: server.dpopNonces, 50 + supportedAlgs: server.serverMetadata.dpop_signing_alg_values_supported, 51 + isAuthServer: false, 52 + fetch, 53 + }); 54 + } 55 + 56 + /** 57 + * user's DID. 58 + */ 59 + get did(): Did { 60 + return this.sub; 61 + } 62 + 63 + /** 64 + * gets the current token set. 65 + * 66 + * @param refresh true to force refresh, false to allow stale, 'auto' for normal 67 + * @returns current token set 68 + */ 69 + private async getTokenSet(refresh: boolean | 'auto'): Promise<TokenSet> { 70 + const { tokenSet } = await this.sessionGetter.getSession(this.sub, refresh); 71 + return tokenSet; 72 + } 73 + 74 + /** 75 + * gets information about the current token. 76 + * 77 + * @param refresh true to force refresh, false to allow stale, 'auto' for normal 78 + * @returns token information 79 + */ 80 + async getTokenInfo(refresh: boolean | 'auto' = 'auto'): Promise<TokenInfo> { 81 + const tokenSet = await this.getTokenSet(refresh); 82 + const expiresAt = tokenSet.expires_at != null ? new Date(tokenSet.expires_at) : undefined; 83 + 84 + return { 85 + expiresAt, 86 + get expired() { 87 + return expiresAt != null ? expiresAt.getTime() < Date.now() - 5_000 : undefined; 88 + }, 89 + scope: tokenSet.scope, 90 + iss: tokenSet.iss, 91 + aud: tokenSet.aud, 92 + sub: tokenSet.sub, 93 + }; 94 + } 95 + 96 + /** 97 + * signs out and revokes the session. 98 + */ 99 + async signOut(): Promise<void> { 100 + try { 101 + const tokenSet = await this.getTokenSet(false); 102 + await this.server.revoke(tokenSet.access_token); 103 + } finally { 104 + await this.sessionGetter.deleteStored(this.sub, new TokenRevokedError(this.sub)); 105 + } 106 + } 107 + 108 + /** 109 + * makes an authenticated request to the user's PDS. 110 + * 111 + * automatically refreshes tokens if needed and retries on auth failure. 112 + * 113 + * @param pathname path relative to the PDS URL 114 + * @param init fetch init options 115 + * @returns fetch response 116 + */ 117 + async handle(pathname: string, init?: RequestInit): Promise<Response> { 118 + // try with current token (auto-refresh if stale) 119 + const tokenSet = await this.getTokenSet('auto'); 120 + 121 + const initialUrl = new URL(pathname, tokenSet.aud); 122 + const initialAuth = `${tokenSet.token_type} ${tokenSet.access_token}`; 123 + 124 + const headers = new Headers(init?.headers); 125 + headers.set('Authorization', initialAuth); 126 + 127 + const initialResponse = await this.dpopFetch(initialUrl, { ...init, headers }); 128 + 129 + // if not an auth error, return as-is 130 + if (!isInvalidTokenResponse(initialResponse)) { 131 + return initialResponse; 132 + } 133 + 134 + // try forcing a refresh 135 + let freshTokenSet: TokenSet; 136 + try { 137 + freshTokenSet = await this.getTokenSet(true); 138 + } catch { 139 + // refresh failed, return original response 140 + return initialResponse; 141 + } 142 + 143 + // can't retry if body was a stream (already consumed) 144 + if (init?.body instanceof ReadableStream) { 145 + return initialResponse; 146 + } 147 + 148 + // retry with fresh token 149 + const freshUrl = new URL(pathname, freshTokenSet.aud); 150 + const freshAuth = `${freshTokenSet.token_type} ${freshTokenSet.access_token}`; 151 + 152 + headers.set('Authorization', freshAuth); 153 + 154 + const freshResponse = await this.dpopFetch(freshUrl, { ...init, headers }); 155 + 156 + // if still failing after refresh, the session is likely invalid 157 + if (isInvalidTokenResponse(freshResponse)) { 158 + await this.sessionGetter.deleteStored(this.sub, new TokenInvalidError(this.sub)); 159 + } 160 + 161 + return freshResponse; 162 + } 163 + } 164 + 165 + /** 166 + * checks if a response indicates an invalid token. 167 + * 168 + * @see {@link https://datatracker.ietf.org/doc/html/rfc6750#section-3} 169 + * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no} 170 + */ 171 + const isInvalidTokenResponse = (response: Response): boolean => { 172 + if (response.status !== 401) { 173 + return false; 174 + } 175 + 176 + const wwwAuth = response.headers.get('WWW-Authenticate'); 177 + if (wwwAuth == null) { 178 + return false; 179 + } 180 + 181 + return ( 182 + (wwwAuth.startsWith('Bearer ') || wwwAuth.startsWith('DPoP ')) && 183 + wwwAuth.includes('error="invalid_token"') 184 + ); 185 + };
+40
packages/oauth/node-client/lib/pkce.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { generatePkce } from './pkce.js'; 4 + 5 + describe('generatePkce', () => { 6 + it('should generate verifier of correct length', async () => { 7 + const pkce = await generatePkce(); 8 + 9 + // RFC 7636 requires 43-128 characters 10 + expect(pkce.verifier.length).toBe(44); 11 + }); 12 + 13 + it('should generate base64url challenge', async () => { 14 + const pkce = await generatePkce(); 15 + 16 + // base64url uses only these characters 17 + expect(pkce.challenge).toMatch(/^[A-Za-z0-9_-]+$/); 18 + }); 19 + 20 + it('should use S256 method', async () => { 21 + const pkce = await generatePkce(); 22 + 23 + expect(pkce.method).toBe('S256'); 24 + }); 25 + 26 + it('should generate unique values', async () => { 27 + const pkce1 = await generatePkce(); 28 + const pkce2 = await generatePkce(); 29 + 30 + expect(pkce1.verifier).not.toBe(pkce2.verifier); 31 + expect(pkce1.challenge).not.toBe(pkce2.challenge); 32 + }); 33 + 34 + it('should generate verifier with valid characters', async () => { 35 + const pkce = await generatePkce(); 36 + 37 + // nanoid uses URL-safe characters 38 + expect(pkce.verifier).toMatch(/^[A-Za-z0-9_-]+$/); 39 + }); 40 + });
+29
packages/oauth/node-client/lib/pkce.ts
··· 1 + import { nanoid } from 'nanoid'; 2 + 3 + import { sha256 } from './utils/crypto.js'; 4 + 5 + /** 6 + * PKCE challenge and verifier pair. 7 + */ 8 + export interface PkceChallenge { 9 + /** code verifier (random string) */ 10 + verifier: string; 11 + /** code challenge (SHA-256 hash of verifier, base64url encoded) */ 12 + challenge: string; 13 + /** challenge method */ 14 + method: 'S256'; 15 + } 16 + 17 + /** 18 + * generates a PKCE code verifier and challenge. 19 + * 20 + * @returns PKCE verifier, challenge (base64url SHA-256), and method 21 + * @see {@link https://datatracker.ietf.org/doc/html/rfc7636#section-4.1} 22 + */ 23 + export const generatePkce = async (): Promise<PkceChallenge> => { 24 + // 43 chars matches 32 bytes base64url encoded per RFC 7636 25 + const verifier = nanoid(44); 26 + const challenge = await sha256(verifier); 27 + 28 + return { verifier, challenge, method: 'S256' }; 29 + };
+151
packages/oauth/node-client/lib/resolvers/authorization-server-metadata.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + 3 + import type { AtprotoAuthorizationServerMetadata } from '../schemas/atproto-authorization-server-metadata.js'; 4 + import { MemoryStore } from '../utils/memory-store.js'; 5 + 6 + import { AuthorizationServerMetadataResolver } from './authorization-server-metadata.js'; 7 + 8 + const createValidMetadata = (issuer: string) => 9 + ({ 10 + issuer, 11 + authorization_endpoint: `${issuer}/oauth/authorize`, 12 + token_endpoint: `${issuer}/oauth/token`, 13 + pushed_authorization_request_endpoint: `${issuer}/oauth/par`, 14 + dpop_signing_alg_values_supported: ['ES256'], 15 + scopes_supported: ['atproto'], 16 + response_types_supported: ['code'], 17 + grant_types_supported: ['authorization_code', 'refresh_token'], 18 + code_challenge_methods_supported: ['S256'], 19 + token_endpoint_auth_methods_supported: ['private_key_jwt'], 20 + token_endpoint_auth_signing_alg_values_supported: ['ES256'], 21 + authorization_response_iss_parameter_supported: true, 22 + require_pushed_authorization_requests: true, 23 + // atproto required field 24 + client_id_metadata_document_supported: true, 25 + }) as AtprotoAuthorizationServerMetadata; 26 + 27 + const createMockResponse = (status: number, body: unknown): Response => { 28 + return new Response(JSON.stringify(body), { 29 + status, 30 + headers: { 'Content-Type': 'application/json' }, 31 + }); 32 + }; 33 + 34 + describe('AuthorizationServerMetadataResolver', () => { 35 + describe('resolve', () => { 36 + it('should fetch and return valid metadata', async () => { 37 + const issuer = 'https://auth.example.com'; 38 + const metadata = createValidMetadata(issuer); 39 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200, metadata)); 40 + 41 + const resolver = new AuthorizationServerMetadataResolver({ 42 + cache: new MemoryStore({}), 43 + fetch: mockFetch, 44 + }); 45 + 46 + const result = await resolver.resolve(issuer); 47 + 48 + expect(result.issuer).toBe(issuer); 49 + expect(result.authorization_endpoint).toBe(`${issuer}/oauth/authorize`); 50 + expect(mockFetch).toHaveBeenCalledTimes(1); 51 + 52 + const request = mockFetch.mock.calls[0][0] as URL; 53 + expect(request.href).toBe(`${issuer}/.well-known/oauth-authorization-server`); 54 + }); 55 + 56 + it('should cache resolved metadata', async () => { 57 + const issuer = 'https://auth.example.com'; 58 + const metadata = createValidMetadata(issuer); 59 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200, metadata)); 60 + 61 + const resolver = new AuthorizationServerMetadataResolver({ 62 + cache: new MemoryStore({}), 63 + fetch: mockFetch, 64 + }); 65 + 66 + await resolver.resolve(issuer); 67 + await resolver.resolve(issuer); 68 + 69 + // second call should use cache 70 + expect(mockFetch).toHaveBeenCalledTimes(1); 71 + }); 72 + 73 + it('should reject http loopback issuers by default', async () => { 74 + const resolver = new AuthorizationServerMetadataResolver({ 75 + cache: new MemoryStore({}), 76 + fetch: vi.fn(), 77 + }); 78 + 79 + // http is only allowed for loopback addresses, but allowHttp must be true 80 + await expect(resolver.resolve('http://localhost:3000')).rejects.toThrow( 81 + 'http issuer not allowed', 82 + ); 83 + }); 84 + 85 + it('should allow http issuers when allowHttp is true', async () => { 86 + const issuer = 'http://localhost:3000'; 87 + const metadata = createValidMetadata(issuer); 88 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200, metadata)); 89 + 90 + const resolver = new AuthorizationServerMetadataResolver({ 91 + cache: new MemoryStore({}), 92 + allowHttp: true, 93 + fetch: mockFetch, 94 + }); 95 + 96 + const result = await resolver.resolve(issuer); 97 + expect(result.issuer).toBe(issuer); 98 + }); 99 + }); 100 + 101 + describe('error handling', () => { 102 + it('should throw on non-200 response', async () => { 103 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(404, { error: 'not found' })); 104 + 105 + const resolver = new AuthorizationServerMetadataResolver({ 106 + cache: new MemoryStore({}), 107 + fetch: mockFetch, 108 + }); 109 + 110 + let error: Error | undefined; 111 + try { 112 + await resolver.resolve('https://auth.example.com'); 113 + } catch (e) { 114 + error = e as Error; 115 + } 116 + 117 + expect(error).toBeDefined(); 118 + expect(error!.message).toContain('unexpected status 404'); 119 + }); 120 + 121 + it('should throw on issuer mismatch', async () => { 122 + const issuer = 'https://auth.example.com'; 123 + const metadata = createValidMetadata('https://other.example.com'); // wrong issuer 124 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200, metadata)); 125 + 126 + const resolver = new AuthorizationServerMetadataResolver({ 127 + cache: new MemoryStore({}), 128 + fetch: mockFetch, 129 + }); 130 + 131 + let error: Error | undefined; 132 + try { 133 + await resolver.resolve(issuer); 134 + } catch (e) { 135 + error = e as Error; 136 + } 137 + 138 + expect(error).toBeDefined(); 139 + expect(error!.message).toContain('issuer mismatch'); 140 + }); 141 + 142 + it('should throw on invalid issuer format', async () => { 143 + const resolver = new AuthorizationServerMetadataResolver({ 144 + cache: new MemoryStore({}), 145 + fetch: vi.fn(), 146 + }); 147 + 148 + await expect(resolver.resolve('not-a-url')).rejects.toThrow(); 149 + }); 150 + }); 151 + });
+92
packages/oauth/node-client/lib/resolvers/authorization-server-metadata.ts
··· 1 + import { parseResponseAsJson, pipe, validateJsonWith } from '@atcute/util-fetch'; 2 + 3 + import { AS_METADATA_MAX_SIZE, JSON_MIME } from '../constants.js'; 4 + import { OAuthResolverError } from '../errors.js'; 5 + import { 6 + atprotoAuthorizationServerMetadataValidator, 7 + type AtprotoAuthorizationServerMetadata, 8 + } from '../schemas/atproto-authorization-server-metadata.js'; 9 + import { oauthIssuerIdentifierSchema } from '../schemas/oauth-issuer-identifier.js'; 10 + import { CachedGetter, type GetCachedOptions } from '../utils/cached-getter.js'; 11 + import type { Store } from '../utils/store.js'; 12 + 13 + /** authorization server metadata cache, keyed by issuer */ 14 + export type AuthorizationServerMetadataCache = Store<string, AtprotoAuthorizationServerMetadata>; 15 + 16 + /** hoisted pipeline for parsing and validating AS metadata */ 17 + const processResponse = pipe( 18 + parseResponseAsJson(JSON_MIME, AS_METADATA_MAX_SIZE), 19 + validateJsonWith(atprotoAuthorizationServerMetadataValidator, { mode: 'passthrough' }), 20 + ); 21 + 22 + export interface AuthorizationServerMetadataResolverOptions { 23 + /** metadata cache, keyed by issuer */ 24 + cache: AuthorizationServerMetadataCache; 25 + /** allow http:// loopback issuers (for development only) */ 26 + allowHttp?: boolean; 27 + /** custom fetch implementation */ 28 + fetch?: typeof globalThis.fetch; 29 + } 30 + 31 + /** 32 + * resolves OAuth authorization server metadata. 33 + * 34 + * @see {@link https://datatracker.ietf.org/doc/html/rfc8414} 35 + */ 36 + export class AuthorizationServerMetadataResolver extends CachedGetter< 37 + string, 38 + AtprotoAuthorizationServerMetadata 39 + > { 40 + private readonly allowHttp: boolean; 41 + private readonly fetch: typeof globalThis.fetch; 42 + 43 + constructor(options: AuthorizationServerMetadataResolverOptions) { 44 + super((issuer, opts) => this.fetchMetadata(issuer, opts), options.cache); 45 + this.allowHttp = options.allowHttp ?? false; 46 + this.fetch = options.fetch ?? globalThis.fetch; 47 + } 48 + 49 + /** 50 + * resolves metadata for an authorization server. 51 + * 52 + * @param issuer authorization server issuer URL 53 + * @param options fetch options 54 + * @returns validated authorization server metadata 55 + */ 56 + async resolve(input: string, options?: GetCachedOptions): Promise<AtprotoAuthorizationServerMetadata> { 57 + // validate issuer format (allows https or loopback http only) 58 + const issuer = oauthIssuerIdentifierSchema.parse(input); 59 + 60 + // loopback http only allowed in development 61 + if (issuer.startsWith('http:') && !this.allowHttp) { 62 + throw new OAuthResolverError(`http issuer not allowed (set allowHttp for development)`); 63 + } 64 + 65 + return this.get(issuer, options); 66 + } 67 + 68 + private async fetchMetadata( 69 + issuer: string, 70 + options: { signal?: AbortSignal }, 71 + ): Promise<AtprotoAuthorizationServerMetadata> { 72 + const metadataUrl = new URL('/.well-known/oauth-authorization-server', issuer); 73 + const response = await (0, this.fetch)(metadataUrl, { 74 + headers: { accept: 'application/json' }, 75 + signal: options.signal, 76 + redirect: 'error', 77 + }); 78 + 79 + if (response.status !== 200) { 80 + throw new OAuthResolverError(`unexpected status ${response.status} from ${metadataUrl}`); 81 + } 82 + 83 + const { json: metadata } = await processResponse(response); 84 + 85 + // validate issuer matches (MIX-UP attack prevention) 86 + if (metadata.issuer !== issuer) { 87 + throw new OAuthResolverError(`issuer mismatch: expected ${issuer}, got ${metadata.issuer}`); 88 + } 89 + 90 + return metadata; 91 + } 92 + }
+131
packages/oauth/node-client/lib/resolvers/index.ts
··· 1 + import type { ActorResolver, ResolvedActor } from '@atcute/identity-resolver'; 2 + import type { ActorIdentifier } from '@atcute/lexicons'; 3 + 4 + import { OAuthResolverError } from '../errors.js'; 5 + import type { AtprotoAuthorizationServerMetadata } from '../schemas/atproto-authorization-server-metadata.js'; 6 + 7 + import { AuthorizationServerMetadataResolver } from './authorization-server-metadata.js'; 8 + import { ProtectedResourceMetadataResolver } from './protected-resource-metadata.js'; 9 + 10 + export interface ResolveOptions { 11 + signal?: AbortSignal; 12 + noCache?: boolean; 13 + } 14 + 15 + export interface ResolvedFromIdentity { 16 + identity: ResolvedActor; 17 + metadata: AtprotoAuthorizationServerMetadata; 18 + } 19 + 20 + export interface ResolvedFromService { 21 + identity?: undefined; 22 + metadata: AtprotoAuthorizationServerMetadata; 23 + } 24 + 25 + /** 26 + * resolves OAuth metadata for AT Protocol services. 27 + * 28 + * combines identity resolution with OAuth metadata discovery. 29 + */ 30 + export class OAuthResolver { 31 + constructor( 32 + readonly actorResolver: ActorResolver, 33 + readonly protectedResourceResolver: ProtectedResourceMetadataResolver, 34 + readonly authorizationServerResolver: AuthorizationServerMetadataResolver, 35 + ) {} 36 + 37 + /** 38 + * resolves OAuth metadata from a service URL (PDS or entryway). 39 + * 40 + * tries as PDS first (protected resource), falls back to entryway (AS directly). 41 + * 42 + * @param url PDS or entryway URL 43 + * @param options resolution options 44 + * @returns AS metadata 45 + */ 46 + async resolveFromService(url: string, options?: ResolveOptions): Promise<ResolvedFromService> { 47 + try { 48 + // try as PDS first (protected resource → AS) 49 + const metadata = await this.getResourceServerMetadata(url, options); 50 + return { metadata }; 51 + } catch (err) { 52 + if (options?.signal?.aborted) { 53 + throw err; 54 + } 55 + 56 + // fall back to trying as entryway (AS directly) 57 + if (err instanceof OAuthResolverError) { 58 + try { 59 + const metadata = await this.authorizationServerResolver.resolve(url, options); 60 + return { metadata }; 61 + } catch { 62 + // fallback failed, throw original error 63 + } 64 + } 65 + 66 + throw err; 67 + } 68 + } 69 + 70 + /** 71 + * resolves OAuth metadata from an identity (handle or DID). 72 + * 73 + * @param input handle or DID 74 + * @param options resolution options 75 + * @returns resolved actor and AS metadata 76 + */ 77 + async resolveFromIdentity(input: ActorIdentifier, options?: ResolveOptions): Promise<ResolvedFromIdentity> { 78 + let identity: ResolvedActor; 79 + try { 80 + identity = await this.actorResolver.resolve(input, options); 81 + } catch (cause) { 82 + throw new OAuthResolverError(`failed to resolve identity: ${input}`, { cause }); 83 + } 84 + 85 + options?.signal?.throwIfAborted(); 86 + 87 + const metadata = await this.getResourceServerMetadata(identity.pds, options); 88 + 89 + return { identity, metadata }; 90 + } 91 + 92 + /** 93 + * resolves AS metadata via a protected resource (PDS). 94 + * 95 + * @param pdsUrl PDS URL 96 + * @param options resolution options 97 + * @returns AS metadata 98 + */ 99 + private async getResourceServerMetadata( 100 + pdsUrl: string | URL, 101 + options?: ResolveOptions, 102 + ): Promise<AtprotoAuthorizationServerMetadata> { 103 + let rsMetadata; 104 + try { 105 + rsMetadata = await this.protectedResourceResolver.resolve(pdsUrl, options); 106 + } catch (cause) { 107 + throw new OAuthResolverError(`failed to resolve protected resource metadata: ${pdsUrl}`, { cause }); 108 + } 109 + 110 + // atproto validation already ensures exactly one AS, but TypeScript doesn't know 111 + const issuer = rsMetadata.authorization_servers[0]; 112 + 113 + options?.signal?.throwIfAborted(); 114 + 115 + let asMetadata; 116 + try { 117 + asMetadata = await this.authorizationServerResolver.resolve(issuer, options); 118 + } catch (cause) { 119 + throw new OAuthResolverError(`failed to resolve AS metadata for issuer: ${issuer}`, { cause }); 120 + } 121 + 122 + // validate that AS actually protects this resource (RFC 9728 section 4) 123 + if (asMetadata.protected_resources) { 124 + if (!asMetadata.protected_resources.includes(rsMetadata.resource)) { 125 + throw new OAuthResolverError(`PDS "${pdsUrl}" not listed in AS "${issuer}" protected_resources`); 126 + } 127 + } 128 + 129 + return asMetadata; 130 + } 131 + }
+142
packages/oauth/node-client/lib/resolvers/protected-resource-metadata.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + 3 + import type { AtprotoProtectedResourceMetadata } from '../schemas/atproto-protected-resource-metadata.js'; 4 + import { MemoryStore } from '../utils/memory-store.js'; 5 + 6 + import { ProtectedResourceMetadataResolver } from './protected-resource-metadata.js'; 7 + 8 + const createValidMetadata = (resource: string) => 9 + ({ 10 + resource, 11 + authorization_servers: ['https://auth.example.com'], 12 + scopes_supported: ['atproto'], 13 + bearer_methods_supported: ['header'], 14 + }) as AtprotoProtectedResourceMetadata; 15 + 16 + const createMockResponse = (status: number, body: unknown): Response => { 17 + return new Response(JSON.stringify(body), { 18 + status, 19 + headers: { 'Content-Type': 'application/json' }, 20 + }); 21 + }; 22 + 23 + describe('ProtectedResourceMetadataResolver', () => { 24 + describe('resolve', () => { 25 + it('should fetch and return valid metadata', async () => { 26 + const resource = 'https://pds.example.com'; 27 + const metadata = createValidMetadata(resource); 28 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200, metadata)); 29 + 30 + const resolver = new ProtectedResourceMetadataResolver({ 31 + cache: new MemoryStore({}), 32 + fetch: mockFetch, 33 + }); 34 + 35 + const result = await resolver.resolve(resource); 36 + 37 + expect(result.resource).toBe(resource); 38 + expect(result.authorization_servers).toContain('https://auth.example.com'); 39 + expect(mockFetch).toHaveBeenCalledTimes(1); 40 + 41 + const request = mockFetch.mock.calls[0][0] as URL; 42 + expect(request.href).toBe(`${resource}/.well-known/oauth-protected-resource`); 43 + }); 44 + 45 + it('should cache resolved metadata', async () => { 46 + const resource = 'https://pds.example.com'; 47 + const metadata = createValidMetadata(resource); 48 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200, metadata)); 49 + 50 + const resolver = new ProtectedResourceMetadataResolver({ 51 + cache: new MemoryStore({}), 52 + fetch: mockFetch, 53 + }); 54 + 55 + await resolver.resolve(resource); 56 + await resolver.resolve(resource); 57 + 58 + expect(mockFetch).toHaveBeenCalledTimes(1); 59 + }); 60 + 61 + it('should strip path from resource URL', async () => { 62 + const resource = 'https://pds.example.com'; 63 + const metadata = createValidMetadata(resource); 64 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200, metadata)); 65 + 66 + const resolver = new ProtectedResourceMetadataResolver({ 67 + cache: new MemoryStore({}), 68 + fetch: mockFetch, 69 + }); 70 + 71 + const result = await resolver.resolve('https://pds.example.com/xrpc/something'); 72 + 73 + expect(result.resource).toBe(resource); 74 + }); 75 + 76 + it('should reject http resources by default', async () => { 77 + const resolver = new ProtectedResourceMetadataResolver({ 78 + cache: new MemoryStore({}), 79 + fetch: vi.fn(), 80 + }); 81 + 82 + await expect(resolver.resolve('http://localhost:3000')).rejects.toThrow( 83 + 'http resource not allowed', 84 + ); 85 + }); 86 + 87 + it('should allow http resources when allowHttp is true', async () => { 88 + const resource = 'http://localhost:3000'; 89 + const metadata = createValidMetadata(resource); 90 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200, metadata)); 91 + 92 + const resolver = new ProtectedResourceMetadataResolver({ 93 + cache: new MemoryStore({}), 94 + allowHttp: true, 95 + fetch: mockFetch, 96 + }); 97 + 98 + const result = await resolver.resolve(resource); 99 + expect(result.resource).toBe(resource); 100 + }); 101 + }); 102 + 103 + describe('error handling', () => { 104 + it('should throw on non-200 response', async () => { 105 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(404, { error: 'not found' })); 106 + 107 + const resolver = new ProtectedResourceMetadataResolver({ 108 + cache: new MemoryStore({}), 109 + fetch: mockFetch, 110 + }); 111 + 112 + await expect(resolver.resolve('https://pds.example.com')).rejects.toThrow( 113 + 'unexpected status 404', 114 + ); 115 + }); 116 + 117 + it('should throw on resource mismatch', async () => { 118 + const metadata = createValidMetadata('https://other-pds.example.com'); 119 + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(200, metadata)); 120 + 121 + const resolver = new ProtectedResourceMetadataResolver({ 122 + cache: new MemoryStore({}), 123 + fetch: mockFetch, 124 + }); 125 + 126 + await expect(resolver.resolve('https://pds.example.com')).rejects.toThrow( 127 + 'resource mismatch', 128 + ); 129 + }); 130 + 131 + it('should throw on invalid protocol', async () => { 132 + const resolver = new ProtectedResourceMetadataResolver({ 133 + cache: new MemoryStore({}), 134 + fetch: vi.fn(), 135 + }); 136 + 137 + await expect(resolver.resolve('ftp://pds.example.com')).rejects.toThrow( 138 + 'invalid resource protocol', 139 + ); 140 + }); 141 + }); 142 + });
+95
packages/oauth/node-client/lib/resolvers/protected-resource-metadata.ts
··· 1 + import { parseResponseAsJson, pipe, validateJsonWith } from '@atcute/util-fetch'; 2 + 3 + import { JSON_MIME, PR_METADATA_MAX_SIZE } from '../constants.js'; 4 + import { OAuthResolverError } from '../errors.js'; 5 + import { 6 + atprotoProtectedResourceMetadataValidator, 7 + type AtprotoProtectedResourceMetadata, 8 + } from '../schemas/atproto-protected-resource-metadata.js'; 9 + import { CachedGetter, type GetCachedOptions } from '../utils/cached-getter.js'; 10 + import type { Store } from '../utils/store.js'; 11 + 12 + /** protected resource metadata cache, keyed by resource origin */ 13 + export type ProtectedResourceMetadataCache = Store<string, AtprotoProtectedResourceMetadata>; 14 + 15 + /** hoisted pipeline for parsing and validating protected resource metadata */ 16 + const processResponse = pipe( 17 + parseResponseAsJson(JSON_MIME, PR_METADATA_MAX_SIZE), 18 + validateJsonWith(atprotoProtectedResourceMetadataValidator, { mode: 'passthrough' }), 19 + ); 20 + 21 + export interface ProtectedResourceMetadataResolverOptions { 22 + /** metadata cache, keyed by resource origin */ 23 + cache: ProtectedResourceMetadataCache; 24 + /** allow http:// resources (for development only) */ 25 + allowHttp?: boolean; 26 + /** custom fetch implementation */ 27 + fetch?: typeof globalThis.fetch; 28 + } 29 + 30 + /** 31 + * resolves OAuth protected resource metadata. 32 + * 33 + * @see {@link https://www.rfc-editor.org/rfc/rfc9728.html} 34 + */ 35 + export class ProtectedResourceMetadataResolver extends CachedGetter< 36 + string, 37 + AtprotoProtectedResourceMetadata 38 + > { 39 + private readonly allowHttp: boolean; 40 + private readonly fetch: typeof globalThis.fetch; 41 + 42 + constructor(options: ProtectedResourceMetadataResolverOptions) { 43 + super((origin, opts) => this.fetchMetadata(origin, opts), options.cache); 44 + this.allowHttp = options.allowHttp ?? false; 45 + this.fetch = options.fetch ?? globalThis.fetch; 46 + } 47 + 48 + /** 49 + * resolves metadata for a protected resource (PDS). 50 + * 51 + * @param resource protected resource URL or origin 52 + * @param options fetch options 53 + * @returns validated protected resource metadata 54 + */ 55 + async resolve( 56 + resource: string | URL, 57 + options?: GetCachedOptions, 58 + ): Promise<AtprotoProtectedResourceMetadata> { 59 + const url = new URL(resource); 60 + 61 + if (url.protocol !== 'https:' && url.protocol !== 'http:') { 62 + throw new OAuthResolverError(`invalid resource protocol: ${url.protocol}`); 63 + } 64 + if (url.protocol === 'http:' && !this.allowHttp) { 65 + throw new OAuthResolverError(`http resource not allowed (set allowHttp for development)`); 66 + } 67 + 68 + return this.get(url.origin, options); 69 + } 70 + 71 + private async fetchMetadata( 72 + origin: string, 73 + options: { signal?: AbortSignal }, 74 + ): Promise<AtprotoProtectedResourceMetadata> { 75 + const metadataUrl = new URL('/.well-known/oauth-protected-resource', origin); 76 + const response = await (0, this.fetch)(metadataUrl, { 77 + headers: { accept: 'application/json' }, 78 + signal: options.signal, 79 + redirect: 'error', 80 + }); 81 + 82 + if (response.status !== 200) { 83 + throw new OAuthResolverError(`unexpected status ${response.status} from ${metadataUrl}`); 84 + } 85 + 86 + const { json: metadata } = await processResponse(response); 87 + 88 + // validate resource matches 89 + if (metadata.resource !== origin) { 90 + throw new OAuthResolverError(`resource mismatch: expected ${origin}, got ${metadata.resource}`); 91 + } 92 + 93 + return metadata; 94 + } 95 + }
+116
packages/oauth/node-client/lib/schemas/atcute-confidential-client-metadata.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { atprotoOAuthScopeSchema } from './atproto-oauth-scope.js'; 4 + import { oauthClientIdDiscoverableSchema } from './oauth-client-id-discoverable.js'; 5 + import { httpsUriSchema, nonLocalWebUriSchema, webUriSchema } from './uri.js'; 6 + import { isLocalHostname } from './utils.js'; 7 + 8 + /** 9 + * user-facing client metadata for configuring a confidential OAuth client. 10 + * 11 + * this is a lean subset of OAuth client metadata, focused on what you actually provide. 12 + * the library will fill in atproto-required values like `dpop_bound_access_tokens`, 13 + * `token_endpoint_auth_method`, and default `grant_types` / `response_types`. 14 + */ 15 + export const confidentialClientMetadataSchema = v 16 + .object({ 17 + /** discoverable https client_id URL (where metadata is hosted) */ 18 + client_id: oauthClientIdDiscoverableSchema, 19 + 20 + /** redirect URIs for authorization responses (must be https) */ 21 + redirect_uris: v 22 + .array(httpsUriSchema) 23 + .assert((arr) => arr.length > 0, `must have at least one redirect URI`) 24 + .assert((arr) => { 25 + for (const uri of arr) { 26 + const url = new URL(uri); 27 + if (url.username || url.password) { 28 + return false; 29 + } 30 + } 31 + return true; 32 + }, `redirect URIs must not contain credentials`), 33 + 34 + /** space-separated scope string (must include "atproto") */ 35 + scope: atprotoOAuthScopeSchema.chain((input) => { 36 + const scopes = input.split(/\s+/); 37 + 38 + for (let i = 0, len = scopes.length; i < len; i++) { 39 + const aka = scopes[i]; 40 + 41 + for (let j = 0; j < i; j++) { 42 + if (aka === scopes[j]) { 43 + return v.err(`duplicate "${aka}" scope`); 44 + } 45 + } 46 + } 47 + 48 + return v.ok(input); 49 + }), 50 + 51 + /** optional client homepage */ 52 + client_uri: webUriSchema.optional(), 53 + /** optional display name */ 54 + client_name: v.string().optional(), 55 + /** optional policy url */ 56 + policy_uri: nonLocalWebUriSchema.optional(), 57 + /** optional terms of service url */ 58 + tos_uri: nonLocalWebUriSchema.optional(), 59 + /** optional logo url */ 60 + logo_uri: nonLocalWebUriSchema.optional(), 61 + 62 + /** optional JWKS URL; if omitted, the library will inline jwks from the keyset */ 63 + jwks_uri: httpsUriSchema.optional(), 64 + }) 65 + .chain((input) => { 66 + const clientIdUrl = new URL(input.client_id); 67 + if (isLocalHostname(clientIdUrl.hostname)) { 68 + return v.err({ message: `client_id hostname is invalid`, path: ['client_id'] }); 69 + } 70 + 71 + if (input.jwks_uri) { 72 + const jwksUrl = new URL(input.jwks_uri); 73 + 74 + if (jwksUrl.username || jwksUrl.password) { 75 + return v.err({ message: `jwks_uri must not contain credentials`, path: ['jwks_uri'] }); 76 + } 77 + 78 + if (isLocalHostname(jwksUrl.hostname)) { 79 + return v.err({ message: `jwks_uri hostname is invalid`, path: ['jwks_uri'] }); 80 + } 81 + 82 + if (jwksUrl.origin !== clientIdUrl.origin) { 83 + return v.err({ message: `jwks_uri must have the same origin as the client_id`, path: ['jwks_uri'] }); 84 + } 85 + } 86 + 87 + // for discoverable clients, client_uri (if provided) must be same-origin parent of client_id 88 + if (input.client_uri) { 89 + const clientUriUrl = new URL(input.client_uri); 90 + 91 + if (isLocalHostname(clientUriUrl.hostname)) { 92 + return v.err({ message: `client_uri hostname is invalid`, path: ['client_uri'] }); 93 + } 94 + 95 + if (clientUriUrl.origin !== clientIdUrl.origin) { 96 + return v.err({ 97 + message: `client_uri must have the same origin as the client_id`, 98 + path: ['client_uri'], 99 + }); 100 + } 101 + 102 + if (clientIdUrl.pathname !== clientUriUrl.pathname) { 103 + const prefix = clientUriUrl.pathname.endsWith('/') 104 + ? clientUriUrl.pathname 105 + : `${clientUriUrl.pathname}/`; 106 + 107 + if (!clientIdUrl.pathname.startsWith(prefix)) { 108 + return v.err({ message: `client_uri must be a parent URL of the client_id`, path: ['client_uri'] }); 109 + } 110 + } 111 + } 112 + 113 + return v.ok(input); 114 + }); 115 + 116 + export type ConfidentialClientMetadata = v.Infer<typeof confidentialClientMetadataSchema>;
+32
packages/oauth/node-client/lib/schemas/atproto-authorization-server-metadata.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { oauthAuthorizationServerMetadataValidator } from './oauth-authorization-server-metadata.js'; 4 + 5 + /** 6 + * AT Protocol authorization server metadata with required fields and assertions. 7 + * 8 + * @see {@link https://atproto.com/specs/oauth} 9 + */ 10 + export const atprotoAuthorizationServerMetadataValidator = oauthAuthorizationServerMetadataValidator.chain( 11 + (data) => { 12 + // atproto requires client_id_metadata_document support 13 + if (data.client_id_metadata_document_supported !== true) { 14 + return v.err({ 15 + message: `atproto requires client_id_metadata_document_supported to be true`, 16 + path: ['client_id_metadata_document_supported'], 17 + }); 18 + } 19 + 20 + // atproto requires PAR 21 + if (!data.pushed_authorization_request_endpoint) { 22 + return v.err({ 23 + message: `atproto requires pushed_authorization_request_endpoint to be true`, 24 + path: ['pushed_authorization_request_endpoint'], 25 + }); 26 + } 27 + 28 + return v.ok(data as typeof data & { pushed_authorization_request_endpoint: string }); 29 + }, 30 + ); 31 + 32 + export type AtprotoAuthorizationServerMetadata = v.Infer<typeof atprotoAuthorizationServerMetadataValidator>;
+18
packages/oauth/node-client/lib/schemas/atproto-oauth-scope.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { isOAuthScope } from './oauth-scope.js'; 4 + import { isSpaceSeparatedValue } from './utils.js'; 5 + 6 + export const ATPROTO_SCOPE_VALUE = 'atproto'; 7 + 8 + const isAtprotoOAuthScope = (input: string): boolean => { 9 + return isOAuthScope(input) && isSpaceSeparatedValue(ATPROTO_SCOPE_VALUE, input); 10 + }; 11 + 12 + /** atproto OAuth scope (must include "atproto") */ 13 + export const atprotoOAuthScopeSchema = v.string().assert(isAtprotoOAuthScope, `invalid atproto OAuth scope`); 14 + 15 + export type AtprotoOAuthScope = v.Infer<typeof atprotoOAuthScopeSchema>; 16 + 17 + /** default scope is for reading identity (did) only */ 18 + export const DEFAULT_ATPROTO_OAUTH_SCOPE: AtprotoOAuthScope = ATPROTO_SCOPE_VALUE;
+20
packages/oauth/node-client/lib/schemas/atproto-oauth-token-response.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { isAtprotoDid } from '@atcute/identity'; 4 + 5 + import { atprotoOAuthScopeSchema } from './atproto-oauth-scope.js'; 6 + import { oauthAuthorizationDetailsSchema } from './oauth-authorization-details.js'; 7 + 8 + export const atprotoOAuthTokenResponseSchema = v.object({ 9 + access_token: v.string(), 10 + token_type: v.literal('DPoP'), 11 + sub: v.string().assert(isAtprotoDid, `must be a did:plc or did:web`), 12 + scope: atprotoOAuthScopeSchema, 13 + refresh_token: v.string().optional(), 14 + expires_in: v.number().optional(), 15 + // https://datatracker.ietf.org/doc/html/rfc9396#name-enriched-authorization-deta 16 + authorization_details: oauthAuthorizationDetailsSchema.optional(), 17 + // OpenID is not compatible with atproto identities 18 + }); 19 + 20 + export type AtprotoOAuthTokenResponse = v.Infer<typeof atprotoOAuthTokenResponseSchema>;
+24
packages/oauth/node-client/lib/schemas/atproto-protected-resource-metadata.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { oauthProtectedResourceMetadataValidator } from './oauth-protected-resource-metadata.js'; 4 + 5 + /** 6 + * AT Protocol protected resource metadata with required fields. 7 + * 8 + * @see {@link https://atproto.com/specs/oauth} 9 + */ 10 + export const atprotoProtectedResourceMetadataValidator = oauthProtectedResourceMetadataValidator.chain( 11 + (data) => { 12 + // atproto requires exactly one authorization server 13 + if (data.authorization_servers?.length !== 1) { 14 + return v.err({ 15 + message: `atproto requires exactly one authorization server`, 16 + path: ['authorization_servers'], 17 + }); 18 + } 19 + 20 + return v.ok(data as typeof data & { authorization_servers: [string] }); 21 + }, 22 + ); 23 + 24 + export type AtprotoProtectedResourceMetadata = v.Infer<typeof atprotoProtectedResourceMetadataValidator>;
+189
packages/oauth/node-client/lib/schemas/jwk.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { isLastOccurrence } from './utils.js'; 4 + 5 + // key usage constants 6 + const PUBLIC_KEY_USAGE = ['verify', 'encrypt', 'wrapKey'] as const; 7 + const PRIVATE_KEY_USAGE = ['sign', 'decrypt', 'unwrapKey', 'deriveKey', 'deriveBits'] as const; 8 + const KEY_USAGE = [...PRIVATE_KEY_USAGE, ...PUBLIC_KEY_USAGE] as const; 9 + 10 + type InternalKeyUsage = (typeof KEY_USAGE)[number]; 11 + 12 + const isPublicKeyUsage = (usage: unknown): usage is (typeof PUBLIC_KEY_USAGE)[number] => { 13 + return (PUBLIC_KEY_USAGE as readonly unknown[]).includes(usage); 14 + }; 15 + 16 + const isPrivateKeyUsage = (usage: unknown): usage is (typeof PRIVATE_KEY_USAGE)[number] => { 17 + return (PRIVATE_KEY_USAGE as readonly unknown[]).includes(usage); 18 + }; 19 + 20 + const isSigKeyUsage = (v: InternalKeyUsage): boolean => v === 'verify'; 21 + const isEncKeyUsage = (v: InternalKeyUsage): boolean => v === 'encrypt' || v === 'wrapKey'; 22 + 23 + export const keyUsageSchema = v.union( 24 + v.literal('verify'), 25 + v.literal('encrypt'), 26 + v.literal('wrapKey'), 27 + v.literal('sign'), 28 + v.literal('decrypt'), 29 + v.literal('unwrapKey'), 30 + v.literal('deriveKey'), 31 + v.literal('deriveBits'), 32 + ); 33 + 34 + export const publicKeyUsageSchema = v.union(v.literal('verify'), v.literal('encrypt'), v.literal('wrapKey')); 35 + 36 + const jwkBaseSchema = v.object({ 37 + kty: v.string(), 38 + alg: v.string().optional(), 39 + kid: v.string().optional(), 40 + use: v.union(v.literal('sig'), v.literal('enc')).optional(), 41 + key_ops: v.array(keyUsageSchema).optional(), 42 + 43 + // X.509 44 + x5c: v.array(v.string()).optional(), 45 + x5t: v.string().optional(), 46 + 'x5t#S256': v.string().optional(), 47 + x5u: v.string().optional(), 48 + 49 + // WebCrypto 50 + ext: v.boolean().optional(), 51 + 52 + // Federation Historical Keys Response 53 + iat: v.number().optional(), 54 + exp: v.number().optional(), 55 + nbf: v.number().optional(), 56 + revoked: v 57 + .object({ 58 + revoked_at: v.number(), 59 + reason: v.string().optional(), 60 + }) 61 + .optional(), 62 + }); 63 + 64 + const jwkRsaKeySchema = jwkBaseSchema.extend({ 65 + kty: v.literal('RSA'), 66 + alg: v 67 + .union( 68 + v.literal('RS256'), 69 + v.literal('RS384'), 70 + v.literal('RS512'), 71 + v.literal('PS256'), 72 + v.literal('PS384'), 73 + v.literal('PS512'), 74 + ) 75 + .optional(), 76 + n: v.string(), 77 + e: v.string(), 78 + d: v.string().optional(), 79 + p: v.string().optional(), 80 + q: v.string().optional(), 81 + dp: v.string().optional(), 82 + dq: v.string().optional(), 83 + qi: v.string().optional(), 84 + oth: v 85 + .array( 86 + v.object({ 87 + r: v.string().optional(), 88 + d: v.string().optional(), 89 + t: v.string().optional(), 90 + }), 91 + ) 92 + .optional(), 93 + }); 94 + 95 + const jwkEcKeySchema = jwkBaseSchema.extend({ 96 + kty: v.literal('EC'), 97 + alg: v.union(v.literal('ES256'), v.literal('ES384'), v.literal('ES512')).optional(), 98 + crv: v.union(v.literal('P-256'), v.literal('P-384'), v.literal('P-521')), 99 + x: v.string(), 100 + y: v.string(), 101 + d: v.string().optional(), 102 + }); 103 + 104 + const jwkEcSecp256k1KeySchema = jwkBaseSchema.extend({ 105 + kty: v.literal('EC'), 106 + alg: v.literal('ES256K').optional(), 107 + crv: v.literal('secp256k1'), 108 + x: v.string(), 109 + y: v.string(), 110 + d: v.string().optional(), 111 + }); 112 + 113 + const jwkOkpKeySchema = jwkBaseSchema.extend({ 114 + kty: v.literal('OKP'), 115 + alg: v.literal('EdDSA').optional(), 116 + crv: v.union(v.literal('Ed25519'), v.literal('Ed448')), 117 + x: v.string(), 118 + d: v.string().optional(), 119 + }); 120 + 121 + const jwkSymKeySchema = jwkBaseSchema.extend({ 122 + kty: v.literal('oct'), 123 + alg: v.union(v.literal('HS256'), v.literal('HS384'), v.literal('HS512')).optional(), 124 + k: v.string(), 125 + }); 126 + 127 + const hasPrivateSecret = <J extends object>(jwk: J): boolean => { 128 + return ('d' in jwk && jwk.d != null) || ('k' in jwk && jwk.k != null); 129 + }; 130 + 131 + const isPublicJwk = <J extends object>(jwk: J): boolean => { 132 + return !hasPrivateSecret(jwk); 133 + }; 134 + 135 + /** JWK schema for known key types */ 136 + export const jwkSchema = v 137 + .union(jwkRsaKeySchema, jwkEcKeySchema, jwkEcSecp256k1KeySchema, jwkOkpKeySchema, jwkSymKeySchema) 138 + .chain((k) => { 139 + // "use" can only be used with public keys 140 + if (k.use != null && !isPublicJwk(k)) { 141 + return v.err({ message: `"use" can only be used with public keys`, path: ['use'] }); 142 + } 143 + 144 + // private key usage not allowed for public keys 145 + if (k.key_ops?.some(isPrivateKeyUsage) && isPublicJwk(k)) { 146 + return v.err({ message: `private key usage not allowed for public keys`, path: ['key_ops'] }); 147 + } 148 + 149 + // key_ops must not contain duplicates 150 + if (k.key_ops && !k.key_ops.every(isLastOccurrence)) { 151 + return v.err({ message: `key_ops must not contain duplicates`, path: ['key_ops'] }); 152 + } 153 + 154 + // "use" and "key_ops" must be consistent 155 + if (k.use != null && k.key_ops != null) { 156 + const consistent = 157 + (k.use === 'sig' && k.key_ops.every(isSigKeyUsage)) || 158 + (k.use === 'enc' && k.key_ops.every(isEncKeyUsage)); 159 + if (!consistent) { 160 + return v.err({ message: `"key_ops" must be consistent with "use"`, path: ['key_ops'] }); 161 + } 162 + } 163 + 164 + return v.ok(k); 165 + }); 166 + 167 + /** public JWK schema (kid required, no private keys) */ 168 + export const jwkPubSchema = jwkSchema.chain((k) => { 169 + if (k.kid == null) { 170 + return v.err({ message: `"kid" is required`, path: ['kid'] }); 171 + } 172 + 173 + if (!isPublicJwk(k)) { 174 + return v.err({ message: `private key not allowed` }); 175 + } 176 + 177 + if (k.key_ops && !k.key_ops.every(isPublicKeyUsage)) { 178 + return v.err({ 179 + message: `"key_ops" must not contain private key usage for public keys`, 180 + path: ['key_ops'], 181 + }); 182 + } 183 + 184 + return v.ok(k); 185 + }); 186 + 187 + export type KeyUsage = v.Infer<typeof keyUsageSchema>; 188 + export type Jwk = v.Infer<typeof jwkSchema>; 189 + export type JwkPub = v.Infer<typeof jwkPubSchema>;
+45
packages/oauth/node-client/lib/schemas/jwks.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { jwkPubSchema, jwkSchema, type Jwk, type JwkPub } from './jwk.js'; 4 + 5 + /** JWKS (JSON Web Key Set) */ 6 + export const jwksSchema = v.object({ 7 + keys: v.array(v.unknown()).chain((input, options) => { 8 + // implementations SHOULD ignore JWKs within a JWK Set that use "kty" 9 + // values that are not understood, are missing required members, or 10 + // have values out of the supported ranges. 11 + const keys: Jwk[] = []; 12 + 13 + for (const item of input) { 14 + const result = jwkSchema.try(item, options); 15 + if (!result.ok) { 16 + continue; 17 + } 18 + 19 + keys.push(result.value); 20 + } 21 + 22 + return v.ok(keys); 23 + }), 24 + }); 25 + 26 + /** public JWKS (JSON Web Key Set with only public keys) */ 27 + export const jwksPubSchema = v.object({ 28 + keys: v.array(v.unknown()).chain((input, options) => { 29 + const keys: JwkPub[] = []; 30 + 31 + for (const item of input) { 32 + const result = jwkPubSchema.try(item, options); 33 + if (!result.ok) { 34 + continue; 35 + } 36 + 37 + keys.push(result.value); 38 + } 39 + 40 + return v.ok(keys); 41 + }), 42 + }); 43 + 44 + export type Jwks = v.Infer<typeof jwksSchema>; 45 + export type JwksPub = v.Infer<typeof jwksPubSchema>;
+43
packages/oauth/node-client/lib/schemas/oauth-authorization-details.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { urlSchema } from './uri.js'; 4 + 5 + /** 6 + * @see {@link https://datatracker.ietf.org/doc/html/rfc9396#section-2 | RFC 9396, Section 2} 7 + */ 8 + export const oauthAuthorizationDetailSchema = v.object({ 9 + type: v.string(), 10 + /** 11 + * an array of strings representing the location of the resource or RS. these 12 + * strings are typically URIs identifying the location of the RS. 13 + */ 14 + locations: v.array(urlSchema).optional(), 15 + /** 16 + * an array of strings representing the kinds of actions to be taken at the 17 + * resource. 18 + */ 19 + actions: v.array(v.string()).optional(), 20 + /** 21 + * an array of strings representing the kinds of data being requested from the 22 + * resource. 23 + */ 24 + datatypes: v.array(v.string()).optional(), 25 + /** 26 + * a string identifier indicating a specific resource available at the API. 27 + */ 28 + identifier: v.string().optional(), 29 + /** 30 + * an array of strings representing the types or levels of privilege being 31 + * requested at the resource. 32 + */ 33 + privileges: v.array(v.string()).optional(), 34 + }); 35 + 36 + export type OAuthAuthorizationDetail = v.Infer<typeof oauthAuthorizationDetailSchema>; 37 + 38 + /** 39 + * @see {@link https://datatracker.ietf.org/doc/html/rfc9396#section-2 | RFC 9396, Section 2} 40 + */ 41 + export const oauthAuthorizationDetailsSchema = v.array(oauthAuthorizationDetailSchema); 42 + 43 + export type OAuthAuthorizationDetails = v.Infer<typeof oauthAuthorizationDetailsSchema>;
+99
packages/oauth/node-client/lib/schemas/oauth-authorization-server-metadata.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { oauthCodeChallengeMethodSchema } from './oauth-code-challenge-method.js'; 4 + import { oauthIssuerIdentifierSchema } from './oauth-issuer-identifier.js'; 5 + import { webUriSchema } from './uri.js'; 6 + 7 + /** 8 + * @see {@link https://datatracker.ietf.org/doc/html/rfc8414} 9 + */ 10 + export const oauthAuthorizationServerMetadataSchema = v.object({ 11 + issuer: oauthIssuerIdentifierSchema, 12 + 13 + claims_supported: v.array(v.string()).optional(), 14 + claims_locales_supported: v.array(v.string()).optional(), 15 + claims_parameter_supported: v.boolean().optional(), 16 + request_parameter_supported: v.boolean().optional(), 17 + request_uri_parameter_supported: v.boolean().optional(), 18 + require_request_uri_registration: v.boolean().optional(), 19 + scopes_supported: v.array(v.string()).optional(), 20 + subject_types_supported: v.array(v.string()).optional(), 21 + response_types_supported: v.array(v.string()).optional(), 22 + response_modes_supported: v.array(v.string()).optional(), 23 + grant_types_supported: v.array(v.string()).optional(), 24 + code_challenge_methods_supported: v.array(oauthCodeChallengeMethodSchema).optional(), 25 + ui_locales_supported: v.array(v.string()).optional(), 26 + id_token_signing_alg_values_supported: v.array(v.string()).optional(), 27 + display_values_supported: v.array(v.string()).optional(), 28 + request_object_signing_alg_values_supported: v.array(v.string()).optional(), 29 + authorization_response_iss_parameter_supported: v.boolean().optional(), 30 + authorization_details_types_supported: v.array(v.string()).optional(), 31 + request_object_encryption_alg_values_supported: v.array(v.string()).optional(), 32 + request_object_encryption_enc_values_supported: v.array(v.string()).optional(), 33 + 34 + jwks_uri: webUriSchema.optional(), 35 + 36 + authorization_endpoint: webUriSchema, 37 + 38 + token_endpoint: webUriSchema, 39 + // https://www.rfc-editor.org/rfc/rfc8414.html#section-2 40 + token_endpoint_auth_methods_supported: v.array(v.string()).optional(), 41 + token_endpoint_auth_signing_alg_values_supported: v.array(v.string()).optional(), 42 + 43 + revocation_endpoint: webUriSchema.optional(), 44 + revocation_endpoint_auth_methods_supported: v.array(v.string()).optional(), 45 + revocation_endpoint_auth_signing_alg_values_supported: v.array(v.string()).optional(), 46 + 47 + introspection_endpoint: webUriSchema.optional(), 48 + introspection_endpoint_auth_methods_supported: v.array(v.string()).optional(), 49 + introspection_endpoint_auth_signing_alg_values_supported: v.array(v.string()).optional(), 50 + 51 + pushed_authorization_request_endpoint: webUriSchema.optional(), 52 + pushed_authorization_request_endpoint_auth_methods_supported: v.array(v.string()).optional(), 53 + pushed_authorization_request_endpoint_auth_signing_alg_values_supported: v.array(v.string()).optional(), 54 + require_pushed_authorization_requests: v.boolean().optional(), 55 + 56 + userinfo_endpoint: webUriSchema.optional(), 57 + end_session_endpoint: webUriSchema.optional(), 58 + registration_endpoint: webUriSchema.optional(), 59 + 60 + // https://datatracker.ietf.org/doc/html/rfc9449#section-5.1 61 + dpop_signing_alg_values_supported: v.array(v.string()).optional(), 62 + 63 + // https://www.rfc-editor.org/rfc/rfc9728.html#section-4 64 + protected_resources: v.array(webUriSchema).optional(), 65 + 66 + // https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html 67 + client_id_metadata_document_supported: v.boolean().optional(), 68 + }); 69 + 70 + export type OAuthAuthorizationServerMetadata = v.Infer<typeof oauthAuthorizationServerMetadataSchema>; 71 + 72 + export const oauthAuthorizationServerMetadataValidator = oauthAuthorizationServerMetadataSchema.chain( 73 + (data) => { 74 + if (data.require_pushed_authorization_requests && !data.pushed_authorization_request_endpoint) { 75 + return v.err({ 76 + message: `"pushed_authorization_request_endpoint" required when "require_pushed_authorization_requests" is true`, 77 + path: ['pushed_authorization_request_endpoint'], 78 + }); 79 + } 80 + 81 + if (data.response_types_supported && !data.response_types_supported.includes('code')) { 82 + return v.err({ 83 + message: `response type "code" is required`, 84 + path: ['response_types_supported'], 85 + }); 86 + } 87 + 88 + if (data.token_endpoint_auth_signing_alg_values_supported?.includes('none')) { 89 + // https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3 90 + // > The value `none` MUST NOT be used. 91 + return v.err({ 92 + message: `client authentication method "none" is not allowed`, 93 + path: ['token_endpoint_auth_signing_alg_values_supported'], 94 + }); 95 + } 96 + 97 + return v.ok(data); 98 + }, 99 + );
+53
packages/oauth/node-client/lib/schemas/oauth-client-id-discoverable.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { oauthClientIdSchema } from './oauth-client-id.js'; 4 + import { httpsUriSchema } from './uri.js'; 5 + import { extractUrlPath, isHostnameIP } from './utils.js'; 6 + 7 + /** 8 + * @see {@link https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html} 9 + */ 10 + export const oauthClientIdDiscoverableSchema = v.string().chain((input, options) => { 11 + // first validate as base client ID 12 + const clientIdResult = oauthClientIdSchema.try(input, options); 13 + if (!clientIdResult.ok) { 14 + return clientIdResult; 15 + } 16 + 17 + // then validate as https URI 18 + const httpsResult = httpsUriSchema.try(input, options); 19 + if (!httpsResult.ok) { 20 + return httpsResult; 21 + } 22 + 23 + const url = new URL(input); 24 + 25 + if (url.username || url.password) { 26 + return v.err(`client ID must not contain credentials`); 27 + } 28 + 29 + if (url.hash) { 30 + return v.err(`client ID must not contain a fragment`); 31 + } 32 + 33 + if (url.pathname === '/') { 34 + return v.err(`client ID must contain a path component (e.g. "/client-metadata.json")`); 35 + } 36 + 37 + if (url.pathname.endsWith('/')) { 38 + return v.err(`client ID path must not end with a trailing slash`); 39 + } 40 + 41 + if (isHostnameIP(url.hostname)) { 42 + return v.err(`client ID hostname must not be an IP address`); 43 + } 44 + 45 + // URL constructor normalizes the URL, so we extract the path manually to 46 + // avoid normalization, then compare it to the normalized path to ensure 47 + // that the URL does not contain path traversal or other unexpected characters 48 + if (extractUrlPath(input) !== url.pathname) { 49 + return v.err(`client ID must be in canonical form ("${url.href}", got "${input}")`); 50 + } 51 + 52 + return v.ok(input); 53 + });
+6
packages/oauth/node-client/lib/schemas/oauth-client-id.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + /** base OAuth client ID (any non-empty string) */ 4 + export const oauthClientIdSchema = v.string().assert((input) => input.length > 0, `must not be empty`); 5 + 6 + export type OAuthClientId = v.Infer<typeof oauthClientIdSchema>;
+83
packages/oauth/node-client/lib/schemas/oauth-client-metadata.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { jwksPubSchema } from './jwks.js'; 4 + import { oauthClientIdSchema } from './oauth-client-id.js'; 5 + import { oauthEndpointAuthMethodSchema } from './oauth-endpoint-auth-method.js'; 6 + import { oauthGrantTypeSchema } from './oauth-grant-type.js'; 7 + import { oauthRedirectUriSchema } from './oauth-redirect-uri.js'; 8 + import { oauthResponseTypeSchema } from './oauth-response-type.js'; 9 + import { oauthScopeSchema } from './oauth-scope.js'; 10 + import { webUriSchema } from './uri.js'; 11 + 12 + const oauthApplicationTypeSchema = v.union(v.literal('web'), v.literal('native')); 13 + 14 + const oauthSubjectTypeSchema = v.union(v.literal('public'), v.literal('pairwise')); 15 + 16 + // simple email validation 17 + const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 18 + 19 + /** 20 + * base OAuth client metadata schema. 21 + * 22 + * @see {@link https://openid.net/specs/openid-connect-registration-1_0.html} 23 + * @see {@link https://datatracker.ietf.org/doc/html/rfc7591} 24 + */ 25 + export const oauthClientMetadataSchema = v.object({ 26 + // https://www.rfc-editor.org/rfc/rfc7591.html#section-2 27 + redirect_uris: v 28 + .array(oauthRedirectUriSchema) 29 + .assert((arr) => arr.length > 0, `must have at least one redirect URI`), 30 + response_types: v.array(oauthResponseTypeSchema).optional(), 31 + // > If omitted, the default is that the client will use only the "code" 32 + // > response type. 33 + // .optional((): OAuthResponseType[] => ['code']) 34 + grant_types: v.array(oauthGrantTypeSchema).optional(), 35 + // > If omitted, the default behavior is that the client will use only the 36 + // > "authorization_code" Grant Type. 37 + // .optional((): OAuthGrantType[] => ['authorization_code']), 38 + scope: oauthScopeSchema.optional(), 39 + // https://www.rfc-editor.org/rfc/rfc7591.html#section-2 40 + token_endpoint_auth_method: oauthEndpointAuthMethodSchema.optional(), 41 + // > If unspecified or omitted, the default is "client_secret_basic" [...]. 42 + // .optional((): OAuthEndpointAuthMethod => 'client_secret_basic'), 43 + token_endpoint_auth_signing_alg: v.string().optional(), 44 + userinfo_signed_response_alg: v.string().optional(), 45 + userinfo_encrypted_response_alg: v.string().optional(), 46 + jwks_uri: webUriSchema.optional(), 47 + jwks: jwksPubSchema.optional(), 48 + application_type: oauthApplicationTypeSchema.optional(), 49 + // .optional((): OAuthApplicationType => 'web'), 50 + subject_type: oauthSubjectTypeSchema.optional(), 51 + // .optional((): OAuthSubjectType => 'public'), 52 + request_object_signing_alg: v.string().optional(), 53 + id_token_signed_response_alg: v.string().optional(), 54 + authorization_signed_response_alg: v.string().optional(), 55 + authorization_encrypted_response_enc: v.literal('A128CBC-HS256').optional(), 56 + authorization_encrypted_response_alg: v.string().optional(), 57 + client_id: oauthClientIdSchema.optional(), 58 + client_name: v.string().optional(), 59 + client_uri: webUriSchema.optional(), 60 + policy_uri: webUriSchema.optional(), 61 + tos_uri: webUriSchema.optional(), 62 + logo_uri: webUriSchema.optional(), 63 + 64 + /** 65 + * default Maximum Authentication Age. specifies that the End-User MUST be 66 + * actively authenticated if the End-User was authenticated longer ago than 67 + * the specified number of seconds. the max_age request parameter overrides 68 + * this default value. if omitted, no default Maximum Authentication Age is 69 + * specified. 70 + */ 71 + default_max_age: v.number().optional(), 72 + require_auth_time: v.boolean().optional(), 73 + contacts: v.array(v.string().assert((s) => EMAIL_RE.test(s), `must be a valid email`)).optional(), 74 + tls_client_certificate_bound_access_tokens: v.boolean().optional(), 75 + 76 + // https://datatracker.ietf.org/doc/html/rfc9449#section-5.2 77 + dpop_bound_access_tokens: v.boolean().optional(), 78 + 79 + // https://datatracker.ietf.org/doc/html/rfc9396#section-14.5 80 + authorization_details_types: v.array(v.string()).optional(), 81 + }); 82 + 83 + export type OAuthClientMetadata = v.Infer<typeof oauthClientMetadataSchema>;
+5
packages/oauth/node-client/lib/schemas/oauth-code-challenge-method.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + export const oauthCodeChallengeMethodSchema = v.union(v.literal('S256'), v.literal('plain')); 4 + 5 + export type OAuthCodeChallengeMethod = v.Infer<typeof oauthCodeChallengeMethodSchema>;
+13
packages/oauth/node-client/lib/schemas/oauth-endpoint-auth-method.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + export const oauthEndpointAuthMethodSchema = v.union( 4 + v.literal('client_secret_basic'), 5 + v.literal('client_secret_jwt'), 6 + v.literal('client_secret_post'), 7 + v.literal('none'), 8 + v.literal('private_key_jwt'), 9 + v.literal('self_signed_tls_client_auth'), 10 + v.literal('tls_client_auth'), 11 + ); 12 + 13 + export type OAuthEndpointAuthMethod = v.Infer<typeof oauthEndpointAuthMethodSchema>;
+13
packages/oauth/node-client/lib/schemas/oauth-grant-type.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + export const oauthGrantTypeSchema = v.union( 4 + v.literal('authorization_code'), 5 + v.literal('implicit'), 6 + v.literal('refresh_token'), 7 + v.literal('password'), // not part of OAuth 2.1 8 + v.literal('client_credentials'), 9 + v.literal('urn:ietf:params:oauth:grant-type:jwt-bearer'), 10 + v.literal('urn:ietf:params:oauth:grant-type:saml2-bearer'), 11 + ); 12 + 13 + export type OAuthGrantType = v.Infer<typeof oauthGrantTypeSchema>;
+30
packages/oauth/node-client/lib/schemas/oauth-issuer-identifier.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { webUriSchema } from './uri.js'; 4 + 5 + export const oauthIssuerIdentifierSchema = webUriSchema.chain((input) => { 6 + // validate the issuer (MIX-UP attacks) 7 + 8 + if (input.endsWith('/')) { 9 + return v.err(`issuer URL must not end with a slash`); 10 + } 11 + 12 + const url = new URL(input); 13 + 14 + if (url.username || url.password) { 15 + return v.err(`issuer URL must not contain a username or password`); 16 + } 17 + 18 + if (url.hash || url.search) { 19 + return v.err(`issuer URL must not contain a query or fragment`); 20 + } 21 + 22 + const canonicalValue = url.pathname === '/' ? url.origin : url.href; 23 + if (input !== canonicalValue) { 24 + return v.err(`issuer URL must be in the canonical form`); 25 + } 26 + 27 + return v.ok(input); 28 + }); 29 + 30 + export type OAuthIssuerIdentifier = v.Infer<typeof oauthIssuerIdentifierSchema>;
+10
packages/oauth/node-client/lib/schemas/oauth-par-response.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + const isPositiveInteger = (n: number): boolean => Number.isInteger(n) && n > 0; 4 + 5 + export const oauthParResponseSchema = v.object({ 6 + request_uri: v.string(), 7 + expires_in: v.number().assert(isPositiveInteger, `must be a positive integer`), 8 + }); 9 + 10 + export type OAuthParResponse = v.Infer<typeof oauthParResponseSchema>;
+89
packages/oauth/node-client/lib/schemas/oauth-protected-resource-metadata.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { oauthIssuerIdentifierSchema } from './oauth-issuer-identifier.js'; 4 + import { webUriSchema } from './uri.js'; 5 + 6 + export const oauthBearerMethodSchema = v.union(v.literal('header'), v.literal('body'), v.literal('query')); 7 + 8 + export type OAuthBearerMethod = v.Infer<typeof oauthBearerMethodSchema>; 9 + 10 + /** 11 + * @see {@link https://www.rfc-editor.org/rfc/rfc9728.html#section-3.2} 12 + */ 13 + export const oauthProtectedResourceMetadataSchema = v.object({ 14 + /** 15 + * REQUIRED. the protected resource's resource identifier, which is a URL that 16 + * uses the https scheme and has no query or fragment components. 17 + */ 18 + resource: webUriSchema, 19 + 20 + /** 21 + * OPTIONAL. JSON array containing a list of OAuth authorization server issuer 22 + * identifiers, as defined in RFC8414, for authorization servers that can be 23 + * used with this protected resource. 24 + */ 25 + authorization_servers: v.array(oauthIssuerIdentifierSchema).optional(), 26 + 27 + /** 28 + * OPTIONAL. URL of the protected resource's JWK Set document. 29 + */ 30 + jwks_uri: webUriSchema.optional(), 31 + 32 + /** 33 + * RECOMMENDED. JSON array containing a list of the OAuth 2.0 scope values that 34 + * are used in authorization requests to request access to this protected resource. 35 + */ 36 + scopes_supported: v.array(v.string()).optional(), 37 + 38 + /** 39 + * OPTIONAL. JSON array containing a list of the supported methods of sending 40 + * an OAuth 2.0 Bearer Token to the protected resource. 41 + */ 42 + bearer_methods_supported: v.array(oauthBearerMethodSchema).optional(), 43 + 44 + /** 45 + * OPTIONAL. JSON array containing a list of the JWS signing algorithms 46 + * supported by the protected resource for signing resource responses. 47 + */ 48 + resource_signing_alg_values_supported: v.array(v.string()).optional(), 49 + 50 + /** 51 + * OPTIONAL. URL of a page containing human-readable information that 52 + * developers might want or need to know when using the protected resource. 53 + */ 54 + resource_documentation: webUriSchema.optional(), 55 + 56 + /** 57 + * OPTIONAL. URL that the protected resource provides to read about the 58 + * protected resource's requirements on how the client can use the data. 59 + */ 60 + resource_policy_uri: webUriSchema.optional(), 61 + 62 + /** 63 + * OPTIONAL. URL that the protected resource provides to read about the 64 + * protected resource's terms of service. 65 + */ 66 + resource_tos_uri: webUriSchema.optional(), 67 + }); 68 + 69 + export const oauthProtectedResourceMetadataValidator = oauthProtectedResourceMetadataSchema.chain((data) => { 70 + const url = new URL(data.resource); 71 + 72 + if (url.search) { 73 + return v.err({ 74 + message: `resource URL must not contain query parameters`, 75 + path: ['resource'], 76 + }); 77 + } 78 + 79 + if (url.hash) { 80 + return v.err({ 81 + message: `resource URL must not contain a fragment`, 82 + path: ['resource'], 83 + }); 84 + } 85 + 86 + return v.ok(data); 87 + }); 88 + 89 + export type OAuthProtectedResourceMetadata = v.Infer<typeof oauthProtectedResourceMetadataSchema>;
+42
packages/oauth/node-client/lib/schemas/oauth-redirect-uri.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { httpsUriSchema, loopbackUriSchema, privateUseUriSchema } from './uri.js'; 4 + 5 + /** 6 + * this is a loopback URI with the additional restriction that the hostname 7 + * `localhost` is not allowed. 8 + * 9 + * @see {@link https://datatracker.ietf.org/doc/html/rfc8252#section-8.3 Loopback Redirect Considerations} RFC8252 10 + * 11 + * > While redirect URIs using localhost (i.e., 12 + * > "http://localhost:{port}/{path}") function similarly to loopback IP 13 + * > redirects described in Section 7.3, the use of localhost is NOT 14 + * > RECOMMENDED. Specifying a redirect URI with the loopback IP literal rather 15 + * > than localhost avoids inadvertently listening on network interfaces other 16 + * > than the loopback interface. It is also less susceptible to client-side 17 + * > firewalls and misconfigured host name resolution on the user's device. 18 + */ 19 + export const loopbackRedirectUriSchema = loopbackUriSchema.chain((input) => { 20 + if (input.startsWith('http://localhost')) { 21 + return v.err( 22 + `use of "localhost" hostname is not allowed (RFC 8252), use a loopback IP such as "127.0.0.1" instead`, 23 + ); 24 + } 25 + return v.ok(input); 26 + }); 27 + 28 + export type LoopbackRedirectUri = v.Infer<typeof loopbackRedirectUriSchema>; 29 + 30 + export const oauthRedirectUriSchema = v.string().chain((input, options) => { 31 + if (input.startsWith('http://')) { 32 + return loopbackRedirectUriSchema.try(input, options); 33 + } 34 + 35 + if (input.startsWith('https://')) { 36 + return httpsUriSchema.try(input, options); 37 + } 38 + 39 + return privateUseUriSchema.try(input, options); 40 + }); 41 + 42 + export type OAuthRedirectUri = v.Infer<typeof oauthRedirectUriSchema>;
+9
packages/oauth/node-client/lib/schemas/oauth-response-mode.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + export const oauthResponseModeSchema = v.union( 4 + v.literal('query'), 5 + v.literal('fragment'), 6 + v.literal('form_post'), 7 + ); 8 + 9 + export type OAuthResponseMode = v.Infer<typeof oauthResponseModeSchema>;
+17
packages/oauth/node-client/lib/schemas/oauth-response-type.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + export const oauthResponseTypeSchema = v.union( 4 + // OAuth2 (https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-10#section-4.1.1) 5 + v.literal('code'), // Authorization Code Grant 6 + v.literal('token'), // Implicit Grant 7 + 8 + // OIDC (https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html) 9 + v.literal('none'), 10 + v.literal('code id_token token'), 11 + v.literal('code id_token'), 12 + v.literal('code token'), 13 + v.literal('id_token token'), 14 + v.literal('id_token'), 15 + ); 16 + 17 + export type OAuthResponseType = v.Infer<typeof oauthResponseTypeSchema>;
+18
packages/oauth/node-client/lib/schemas/oauth-scope.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + // scope = scope-token *( SP scope-token ) 4 + // scope-token = 1*( %x21 / %x23-5B / %x5D-7E ) 5 + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11#section-1.4.1 6 + export const OAUTH_SCOPE_REGEXP = /^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/; 7 + 8 + export const isOAuthScope = (input: string): boolean => OAUTH_SCOPE_REGEXP.test(input); 9 + 10 + /** 11 + * a (single) space separated list of non empty printable ASCII char string 12 + * (except backslash and double quote). 13 + * 14 + * @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11#section-1.4.1} 15 + */ 16 + export const oauthScopeSchema = v.string().assert(isOAuthScope, `invalid OAuth scope`); 17 + 18 + export type OAuthScope = v.Infer<typeof oauthScopeSchema>;
+22
packages/oauth/node-client/lib/schemas/oauth-token-response.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { oauthAuthorizationDetailsSchema } from './oauth-authorization-details.js'; 4 + import { oauthTokenTypeSchema } from './oauth-token-type.js'; 5 + 6 + /** 7 + * @see {@link https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 | RFC 6749 (OAuth2), Section 5.1} 8 + */ 9 + export const oauthTokenResponseSchema = v.object({ 10 + // https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 11 + access_token: v.string(), 12 + token_type: oauthTokenTypeSchema, 13 + scope: v.string().optional(), 14 + refresh_token: v.string().optional(), 15 + expires_in: v.number().optional(), 16 + // https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse 17 + id_token: v.string().optional(), 18 + // https://datatracker.ietf.org/doc/html/rfc9396#name-enriched-authorization-deta 19 + authorization_details: oauthAuthorizationDetailsSchema.optional(), 20 + }); 21 + 22 + export type OAuthTokenResponse = v.Infer<typeof oauthTokenResponseSchema>;
+15
packages/oauth/node-client/lib/schemas/oauth-token-type.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + /** token type (case-insensitive input, normalized output) */ 4 + export const oauthTokenTypeSchema = v.string().chain((input) => { 5 + const lower = input.toLowerCase(); 6 + if (lower === 'dpop') { 7 + return v.ok('DPoP'); 8 + } 9 + if (lower === 'bearer') { 10 + return v.ok('Bearer'); 11 + } 12 + return v.err(`must be "DPoP" or "Bearer"`); 13 + }); 14 + 15 + export type OAuthTokenType = v.Infer<typeof oauthTokenTypeSchema>;
+100
packages/oauth/node-client/lib/schemas/uri.ts
··· 1 + import * as v from '@badrap/valita'; 2 + 3 + import { isHostnameIP, isLocalHostname, isLoopbackHost } from './utils.js'; 4 + 5 + /** 6 + * valid, but potentially dangerous URL (`data:`, `file:`, `javascript:`, etc.). 7 + * 8 + * any value that matches this schema is safe to parse using `new URL()`. 9 + */ 10 + export const urlSchema = v.string().chain((input) => { 11 + if (input.includes(':') && URL.canParse(input)) { 12 + return v.ok(input); 13 + } 14 + return v.err(`must be a valid url`); 15 + }); 16 + 17 + /** loopback URL (http://localhost, http://127.0.0.1, http://[::1]) */ 18 + export const loopbackUriSchema = urlSchema.chain((input) => { 19 + if (!input.startsWith('http://')) { 20 + return v.err(`loopback url must use http: protocol`); 21 + } 22 + 23 + const url = new URL(input); 24 + if (!isLoopbackHost(url.hostname)) { 25 + return v.err(`loopback url must use localhost, 127.0.0.1, or [::1] as hostname`); 26 + } 27 + 28 + return v.ok(input); 29 + }); 30 + 31 + /** HTTPS URL with additional restrictions */ 32 + export const httpsUriSchema = urlSchema.chain((input) => { 33 + if (!input.startsWith('https://')) { 34 + return v.err(`url must use https: protocol`); 35 + } 36 + 37 + const url = new URL(input); 38 + 39 + if (isLoopbackHost(url.hostname)) { 40 + return v.err(`https url must not use a loopback host`); 41 + } 42 + 43 + if (!isHostnameIP(url.hostname)) { 44 + if (!url.hostname.includes('.')) { 45 + return v.err(`domain name must contain at least two segments`); 46 + } 47 + if (url.hostname.endsWith('.local')) { 48 + return v.err(`domain name must not end with .local`); 49 + } 50 + } 51 + 52 + return v.ok(input); 53 + }); 54 + 55 + /** web URL (either loopback http or https) */ 56 + export const webUriSchema = urlSchema.chain((input, options) => { 57 + if (input.startsWith('http://')) { 58 + return loopbackUriSchema.try(input, options); 59 + } 60 + 61 + if (input.startsWith('https://')) { 62 + return httpsUriSchema.try(input, options); 63 + } 64 + 65 + return v.err(`url must use http: or https: protocol`); 66 + }); 67 + 68 + /** web URL with a non-local hostname */ 69 + export const nonLocalWebUriSchema = webUriSchema.chain((input) => { 70 + const url = new URL(input); 71 + if (isLocalHostname(url.hostname)) { 72 + return v.err(`hostname is invalid`); 73 + } 74 + return v.ok(input); 75 + }); 76 + 77 + /** private-use URI scheme (e.g., com.example.app:/callback) */ 78 + export const privateUseUriSchema = urlSchema.chain((input) => { 79 + const dotIdx = input.indexOf('.'); 80 + const colonIdx = input.indexOf(':'); 81 + 82 + if (dotIdx === -1 || colonIdx === -1 || dotIdx > colonIdx) { 83 + return v.err(`private-use uri scheme must contain a dot in the protocol`); 84 + } 85 + 86 + const url = new URL(input); 87 + const scheme = url.protocol.slice(0, -1); 88 + const domain = scheme.split('.').reverse().join('.'); 89 + 90 + if (isLocalHostname(domain)) { 91 + return v.err(`private-use uri scheme must not be a local hostname`); 92 + } 93 + 94 + // RFC 8252: private-use URIs must use single slash after scheme 95 + if (url.href.startsWith(`${url.protocol}//`) || url.username || url.password || url.hostname || url.port) { 96 + return v.err(`private-use uri must be in the form scheme:/<path>`); 97 + } 98 + 99 + return v.ok(input); 100 + });
+113
packages/oauth/node-client/lib/schemas/utils.ts
··· 1 + /** 2 + * checks if a hostname is a loopback address 3 + */ 4 + export const isLoopbackHost = (hostname: string): boolean => { 5 + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; 6 + }; 7 + 8 + /** 9 + * checks if a hostname is an IP address (IPv4 or IPv6) 10 + */ 11 + export const isHostnameIP = (hostname: string): boolean => { 12 + // IPv4 13 + if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { 14 + return true; 15 + } 16 + // IPv6 17 + if (hostname.startsWith('[') && hostname.endsWith(']')) { 18 + return true; 19 + } 20 + return false; 21 + }; 22 + 23 + /** 24 + * checks if a hostname is a local/reserved hostname 25 + * 26 + * returns true for single-segment hostnames and reserved TLDs 27 + */ 28 + export const isLocalHostname = (hostname: string): boolean => { 29 + const parts = hostname.split('.'); 30 + if (parts.length < 2) { 31 + return true; 32 + } 33 + 34 + const tld = parts.at(-1)!.toLowerCase(); 35 + return tld === 'test' || tld === 'local' || tld === 'localhost' || tld === 'invalid' || tld === 'example'; 36 + }; 37 + 38 + /** 39 + * extracts the path from a URL without relying on URL constructor normalization 40 + * 41 + * this is needed because the URL constructor normalizes paths (e.g., removes `.` and `..` segments), 42 + * which can be used to bypass validation checks 43 + */ 44 + export const extractUrlPath = (url: string): string => { 45 + const endOfProtocol = url.startsWith('https://') ? 8 : url.startsWith('http://') ? 7 : -1; 46 + if (endOfProtocol === -1) { 47 + throw new TypeError(`url must use https: or http: protocol`); 48 + } 49 + 50 + const hashIdx = url.indexOf('#', endOfProtocol); 51 + const questionIdx = url.indexOf('?', endOfProtocol); 52 + 53 + const queryStrIdx = questionIdx !== -1 && (hashIdx === -1 || questionIdx < hashIdx) ? questionIdx : -1; 54 + 55 + const pathEnd = 56 + hashIdx === -1 57 + ? queryStrIdx === -1 58 + ? url.length 59 + : queryStrIdx 60 + : queryStrIdx === -1 61 + ? hashIdx 62 + : Math.min(hashIdx, queryStrIdx); 63 + 64 + const slashIdx = url.indexOf('/', endOfProtocol); 65 + const pathStart = slashIdx === -1 || slashIdx > pathEnd ? pathEnd : slashIdx; 66 + 67 + if (endOfProtocol === pathStart) { 68 + throw new TypeError(`url must contain a host`); 69 + } 70 + 71 + return url.substring(pathStart, pathEnd) || '/'; 72 + }; 73 + 74 + /** 75 + * checks if an item is the last occurrence in an array (for duplicate detection) 76 + */ 77 + export const isLastOccurrence = <T>(item: T, index: number, array: readonly T[]): boolean => { 78 + return array.lastIndexOf(item) === index; 79 + }; 80 + 81 + /** 82 + * checks if a space-separated string contains a specific value 83 + * 84 + * optimized version of `input.split(' ').includes(value)` 85 + */ 86 + export const isSpaceSeparatedValue = (value: string, input: string): boolean => { 87 + const inputLength = input.length; 88 + const valueLength = value.length; 89 + 90 + if (inputLength < valueLength) { 91 + return false; 92 + } 93 + 94 + let idx = input.indexOf(value); 95 + let idxEnd: number; 96 + 97 + while (idx !== -1) { 98 + idxEnd = idx + valueLength; 99 + 100 + if ( 101 + // at beginning or preceded by space 102 + (idx === 0 || input.charCodeAt(idx - 1) === 32) && 103 + // at end or followed by space 104 + (idxEnd === inputLength || input.charCodeAt(idxEnd) === 32) 105 + ) { 106 + return true; 107 + } 108 + 109 + idx = input.indexOf(value, idxEnd + 1); 110 + } 111 + 112 + return false; 113 + };
+206
packages/oauth/node-client/lib/session-getter.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + 3 + import { 4 + AuthMethodUnsatisfiableError, 5 + OAuthResponseError, 6 + TokenInvalidError, 7 + TokenRefreshError, 8 + TokenRevokedError, 9 + } from './errors.js'; 10 + import type { OAuthServerFactory } from './oauth-server-factory.js'; 11 + import type { SessionStore, StoredSession } from './types/sessions.js'; 12 + import { CachedGetter, type GetCachedOptions } from './utils/cached-getter.js'; 13 + import { type LockFunction, requestLock as defaultRequestLock } from './utils/lock.js'; 14 + 15 + export type { SessionStore, StoredSession }; 16 + 17 + export type SessionEventType = 'updated' | 'deleted'; 18 + 19 + export interface SessionUpdatedEvent { 20 + type: 'updated'; 21 + sub: Did; 22 + session: StoredSession; 23 + } 24 + 25 + export interface SessionDeletedEvent { 26 + type: 'deleted'; 27 + sub: Did; 28 + cause: unknown; 29 + } 30 + 31 + export type SessionEvent = SessionUpdatedEvent | SessionDeletedEvent; 32 + 33 + export type SessionEventListener = (event: SessionEvent) => void; 34 + 35 + export interface SessionGetterOptions { 36 + /** session store */ 37 + sessionStore: SessionStore; 38 + /** server factory for creating OAuthServerAgent */ 39 + serverFactory: OAuthServerFactory; 40 + /** lock function for preventing concurrent refresh */ 41 + requestLock?: LockFunction; 42 + } 43 + 44 + /** 45 + * manages session retrieval and automatic token refresh. 46 + * 47 + * wraps a session store with caching and staleness checking. 48 + * automatically refreshes tokens when they're about to expire. 49 + */ 50 + export class SessionGetter extends CachedGetter<Did, StoredSession> { 51 + private readonly listeners = new Set<SessionEventListener>(); 52 + private readonly requestLock: LockFunction; 53 + 54 + constructor(options: SessionGetterOptions) { 55 + const { sessionStore, serverFactory, requestLock = defaultRequestLock } = options; 56 + 57 + super( 58 + // getter function - refreshes the token 59 + async (sub, opts, storedSession) => { 60 + if (storedSession === undefined) { 61 + const cause = new TokenRefreshError(sub, 'session was deleted by another process'); 62 + this.dispatchEvent({ type: 'deleted', sub, cause }); 63 + throw cause; 64 + } 65 + 66 + const { dpopKey, authMethod, tokenSet } = storedSession; 67 + 68 + if (sub !== tokenSet.sub) { 69 + throw new TokenRefreshError(sub, 'stored session sub mismatch'); 70 + } 71 + 72 + if (!tokenSet.refresh_token) { 73 + throw new TokenRefreshError(sub, 'no refresh token available'); 74 + } 75 + 76 + const server = await serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey); 77 + 78 + // don't abort after this point - refresh tokens are single-use 79 + opts.signal?.throwIfAborted(); 80 + 81 + try { 82 + const newTokenSet = await server.refresh(tokenSet); 83 + 84 + if (sub !== newTokenSet.sub) { 85 + throw new TokenRefreshError(sub, 'token set sub mismatch after refresh'); 86 + } 87 + 88 + return { 89 + dpopKey, 90 + authMethod: server.authMethod, 91 + tokenSet: newTokenSet, 92 + }; 93 + } catch (cause) { 94 + // invalid_grant means token was revoked or already used 95 + if ( 96 + cause instanceof OAuthResponseError && 97 + cause.status === 400 && 98 + cause.error === 'invalid_grant' 99 + ) { 100 + const msg = cause.errorDescription ?? 'session was revoked'; 101 + throw new TokenRefreshError(sub, msg, { cause }); 102 + } 103 + 104 + throw cause; 105 + } 106 + }, 107 + sessionStore, 108 + { 109 + isStale(_sub, { tokenSet }) { 110 + if (tokenSet.expires_at == null) { 111 + return false; 112 + } 113 + // refresh if token expires within 10-40 seconds (randomized to reduce concurrent refreshes) 114 + const buffer = 10_000 + 30_000 * Math.random(); 115 + return tokenSet.expires_at < Date.now() + buffer; 116 + }, 117 + async onStoreError(err, _sub, { tokenSet, dpopKey, authMethod }) { 118 + if (!(err instanceof AuthMethodUnsatisfiableError)) { 119 + try { 120 + const server = await serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey); 121 + await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token); 122 + } catch { 123 + // ignore revocation errors 124 + } 125 + } 126 + throw err; 127 + }, 128 + deleteOnError(err) { 129 + return ( 130 + err instanceof TokenRefreshError || 131 + err instanceof TokenRevokedError || 132 + err instanceof TokenInvalidError || 133 + err instanceof AuthMethodUnsatisfiableError 134 + ); 135 + }, 136 + }, 137 + ); 138 + 139 + this.requestLock = requestLock; 140 + } 141 + 142 + /** 143 + * adds a listener for session events. 144 + */ 145 + addEventListener(listener: SessionEventListener): void { 146 + this.listeners.add(listener); 147 + } 148 + 149 + /** 150 + * removes a session event listener. 151 + */ 152 + removeEventListener(listener: SessionEventListener): void { 153 + this.listeners.delete(listener); 154 + } 155 + 156 + private dispatchEvent(event: SessionEvent): void { 157 + for (const listener of this.listeners) { 158 + try { 159 + listener(event); 160 + } catch { 161 + // ignore listener errors 162 + } 163 + } 164 + } 165 + 166 + override async setStored(sub: Did, session: StoredSession): Promise<void> { 167 + if (sub !== session.tokenSet.sub) { 168 + throw new TypeError('token set does not match the expected sub'); 169 + } 170 + await super.setStored(sub, session); 171 + this.dispatchEvent({ type: 'updated', sub, session }); 172 + } 173 + 174 + override async deleteStored(sub: Did, cause?: unknown): Promise<void> { 175 + await super.deleteStored(sub, cause); 176 + this.dispatchEvent({ type: 'deleted', sub, cause }); 177 + } 178 + 179 + /** 180 + * gets a session, optionally forcing a refresh. 181 + * 182 + * @param sub user's DID 183 + * @param refresh true to force refresh, false to allow stale, 'auto' for normal behavior 184 + * @returns session data 185 + */ 186 + async getSession(sub: Did, refresh: boolean | 'auto' = 'auto'): Promise<StoredSession> { 187 + return this.get(sub, { 188 + noCache: refresh === true, 189 + allowStale: refresh === false, 190 + }); 191 + } 192 + 193 + override async get(sub: Did, options?: GetCachedOptions): Promise<StoredSession> { 194 + // use lock to prevent concurrent refresh for the same sub 195 + const session = await this.requestLock(`oauth-session-${sub}`, async () => { 196 + const signal = options?.signal ?? AbortSignal.timeout(30_000); 197 + return super.get(sub, { ...options, signal }); 198 + }); 199 + 200 + if (sub !== session.tokenSet.sub) { 201 + throw new Error('token set does not match the expected sub'); 202 + } 203 + 204 + return session; 205 + } 206 + }
+1
packages/oauth/node-client/lib/types/misc.ts
··· 1 + export type Awaitable<T> = T | Promise<T>;
+22
packages/oauth/node-client/lib/types/sessions.ts
··· 1 + import type { JWK } from 'jose'; 2 + 3 + import type { Did } from '@atcute/lexicons'; 4 + 5 + import type { ClientAuthMethod } from '../oauth-client-auth.js'; 6 + import type { Store } from '../utils/store.js'; 7 + import type { TokenSet } from './token-set.js'; 8 + 9 + /** 10 + * stored session data, keyed by DID. 11 + */ 12 + export interface StoredSession { 13 + /** DPoP private key */ 14 + dpopKey: JWK; 15 + /** client authentication method */ 16 + authMethod: ClientAuthMethod; 17 + /** token data (includes iss, aud, sub, scope, tokens) */ 18 + tokenSet: TokenSet; 19 + } 20 + 21 + /** session store, keyed by DID */ 22 + export type SessionStore = Store<Did, StoredSession>;
+31
packages/oauth/node-client/lib/types/states.ts
··· 1 + import type { JWK } from 'jose'; 2 + 3 + import type { Did } from '@atcute/lexicons'; 4 + 5 + import type { ClientAuthMethod } from '../oauth-client-auth.js'; 6 + import type { Store } from '../utils/store.js'; 7 + 8 + /** 9 + * stored authorization state, keyed by state ID (short-lived). 10 + */ 11 + export interface StoredState { 12 + /** DPoP private key */ 13 + dpopKey: JWK; 14 + /** client authentication method */ 15 + authMethod: ClientAuthMethod; 16 + /** PKCE code verifier */ 17 + pkceVerifier: string; 18 + /** authorization server issuer URL */ 19 + issuer: string; 20 + /** redirect URI used (for token exchange) */ 21 + redirectUri: string; 22 + /** expected DID if resolved during authorize() */ 23 + sub?: Did; 24 + /** user-provided state to pass through */ 25 + userState?: unknown; 26 + /** expiry unix timestamp (typically ~10 minutes) */ 27 + expiresAt: number; 28 + } 29 + 30 + /** authorization state store, keyed by state ID */ 31 + export type StateStore = Store<string, StoredState>;
+26
packages/oauth/node-client/lib/types/token-set.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + 3 + import type { AtprotoOAuthScope } from '../schemas/atproto-oauth-scope.js'; 4 + 5 + /** 6 + * token set returned from token operations (exchange, refresh). 7 + */ 8 + export interface TokenSet { 9 + /** authorization server issuer */ 10 + iss: string; 11 + /** user's DID */ 12 + sub: Did; 13 + /** resource server (PDS) URL */ 14 + aud: string; 15 + /** granted scope */ 16 + scope: AtprotoOAuthScope; 17 + 18 + /** access token */ 19 + access_token: string; 20 + /** refresh token (if granted) */ 21 + refresh_token?: string; 22 + /** token type (always DPoP for atproto) */ 23 + token_type: 'DPoP'; 24 + /** expiration time as unix timestamp (milliseconds) */ 25 + expires_at?: number; 26 + }
+131
packages/oauth/node-client/lib/utils/cached-getter.ts
··· 1 + import type { Awaitable } from '../types/misc.js'; 2 + import type { GetOptions, Store } from './store.js'; 3 + 4 + export interface GetCachedOptions { 5 + signal?: AbortSignal; 6 + noCache?: boolean; 7 + allowStale?: boolean; 8 + } 9 + 10 + export interface GetterOptions { 11 + signal?: AbortSignal; 12 + noCache: boolean; 13 + } 14 + 15 + export type Getter<K, V> = (key: K, options: GetterOptions, storedValue: V | undefined) => Awaitable<V>; 16 + 17 + export interface CachedGetterOptions<K, V> { 18 + isStale?(key: K, value: V): Awaitable<boolean>; 19 + onStoreError?(err: unknown, key: K, value: V): Awaitable<void>; 20 + deleteOnError?(err: unknown, key: K, value: V): Awaitable<boolean>; 21 + } 22 + 23 + type PendingItem<V> = Promise<{ value: V; fresh: boolean }>; 24 + 25 + const returnTrue = () => true; 26 + const returnFalse = () => false; 27 + 28 + export class CachedGetter<K, V> { 29 + #pending = new Map<K, PendingItem<V>>(); 30 + 31 + constructor( 32 + readonly getter: Getter<K, V>, 33 + readonly store: Store<K, V>, 34 + readonly options: CachedGetterOptions<K, V> = {}, 35 + ) {} 36 + 37 + async get(key: K, options: GetCachedOptions = {}): Promise<V> { 38 + const { signal, allowStale = false, noCache = false } = options; 39 + const { isStale, deleteOnError } = this.options; 40 + 41 + signal?.throwIfAborted(); 42 + 43 + const allowStored: (value: V) => Awaitable<boolean> = noCache 44 + ? returnFalse 45 + : allowStale || isStale == null 46 + ? returnTrue 47 + : async (value: V) => !(await isStale(key, value)); 48 + 49 + let promise: PendingItem<V> | undefined; 50 + 51 + // wait for the previous request for the same key to finish 52 + while ((promise = this.#pending.get(key)) !== undefined) { 53 + try { 54 + const { value, fresh } = await promise; 55 + 56 + if (fresh) { 57 + return value; 58 + } 59 + 60 + if (await allowStored(value)) { 61 + return value; 62 + } 63 + } catch { 64 + // ignore errors from previous requests 65 + } 66 + 67 + signal?.throwIfAborted(); 68 + } 69 + 70 + // now we start our own. 71 + promise = (async (): PendingItem<V> => { 72 + try { 73 + const storedValue = await this.getStored(key, { signal }); 74 + 75 + if (storedValue !== undefined && (await allowStored(storedValue))) { 76 + return { fresh: false, value: storedValue }; 77 + } 78 + 79 + let value: V; 80 + try { 81 + const options: GetterOptions = { signal, noCache }; 82 + 83 + value = await (0, this.getter)(key, options, storedValue); 84 + } catch (err) { 85 + if (storedValue !== undefined && deleteOnError !== undefined) { 86 + try { 87 + if (await deleteOnError(err, key, storedValue)) { 88 + await this.deleteStored(key, err); 89 + } 90 + } catch (error) { 91 + throw new AggregateError([err, error], `error while deleting stored value`); 92 + } 93 + } 94 + 95 + throw err; 96 + } 97 + 98 + await this.setStored(key, value); 99 + return { fresh: true, value: value }; 100 + } finally { 101 + this.#pending.delete(key); 102 + } 103 + })(); 104 + 105 + this.#pending.set(key, promise); 106 + 107 + const { value } = await promise; 108 + return value; 109 + } 110 + 111 + async getStored(key: K, options?: GetOptions): Promise<V | undefined> { 112 + try { 113 + return await this.store.get(key, options); 114 + } catch (err) { 115 + return undefined; 116 + } 117 + } 118 + 119 + async setStored(key: K, value: V): Promise<void> { 120 + try { 121 + await this.store.set(key, value); 122 + } catch (err) { 123 + const onStoreError = this.options?.onStoreError; 124 + await onStoreError?.(err, key, value); 125 + } 126 + } 127 + 128 + async deleteStored(key: K, _cause?: unknown): Promise<void> { 129 + await this.store.delete(key); 130 + } 131 + }
+14
packages/oauth/node-client/lib/utils/crypto.ts
··· 1 + import { toBase64Url } from '@atcute/multibase'; 2 + import { encodeUtf8, toSha256 } from '@atcute/uint8array'; 3 + 4 + /** 5 + * computes SHA-256 hash and returns base64url-encoded result. 6 + * 7 + * @param input string to hash 8 + * @returns base64url-encoded SHA-256 hash 9 + */ 10 + export const sha256 = async (input: string): Promise<string> => { 11 + const bytes = encodeUtf8(input); 12 + const digest = await toSha256(bytes); 13 + return toBase64Url(digest); 14 + };
+51
packages/oauth/node-client/lib/utils/lock.ts
··· 1 + /** 2 + * function that acquires a lock by name and runs a callback. 3 + */ 4 + export type LockFunction = <T>(name: string, fn: () => Promise<T>) => Promise<T>; 5 + 6 + const locks = new Map<string, Promise<void>>(); 7 + 8 + /** 9 + * acquires a lock by name, ensuring only one callback runs at a time per name. 10 + * 11 + * @param name lock identifier 12 + * @returns release function 13 + */ 14 + const acquireLock = (name: string): Promise<() => void> => { 15 + return new Promise((resolveAcquire) => { 16 + const prev = locks.get(name) ?? Promise.resolve(); 17 + const next = prev.then(() => { 18 + return new Promise<void>((resolveRelease) => { 19 + const release = () => { 20 + // only delete the lock if it is still the current one 21 + if (locks.get(name) === next) { 22 + locks.delete(name); 23 + } 24 + resolveRelease(); 25 + }; 26 + resolveAcquire(release); 27 + }); 28 + }); 29 + 30 + locks.set(name, next); 31 + }); 32 + }; 33 + 34 + /** 35 + * runs a callback with an exclusive lock by name. 36 + * 37 + * ensures only one callback runs at a time for a given lock name. 38 + * this is the default in-memory implementation for single-process use. 39 + * 40 + * @param name lock identifier 41 + * @param fn callback to run while holding the lock 42 + * @returns callback result 43 + */ 44 + export const requestLock: LockFunction = async (name, fn) => { 45 + const release = await acquireLock(name); 46 + try { 47 + return await fn(); 48 + } finally { 49 + release(); 50 + } 51 + };
+185
packages/oauth/node-client/lib/utils/lru.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { LRUCache } from './lru.js'; 4 + 5 + describe('LRUCache', () => { 6 + describe('basic operations', () => { 7 + it('should set and get values', () => { 8 + const cache = new LRUCache<string, number>(3); 9 + cache.set('a', 1); 10 + cache.set('b', 2); 11 + 12 + expect(cache.get('a')).toBe(1); 13 + expect(cache.get('b')).toBe(2); 14 + expect(cache.get('c')).toBeUndefined(); 15 + }); 16 + 17 + it('should update existing values', () => { 18 + const cache = new LRUCache<string, number>(3); 19 + cache.set('a', 1); 20 + cache.set('a', 10); 21 + 22 + expect(cache.get('a')).toBe(10); 23 + }); 24 + 25 + it('should delete values', () => { 26 + const cache = new LRUCache<string, number>(3); 27 + cache.set('a', 1); 28 + cache.set('b', 2); 29 + 30 + expect(cache.delete('a')).toBe(true); 31 + expect(cache.get('a')).toBeUndefined(); 32 + expect(cache.delete('a')).toBe(false); 33 + expect(cache.get('b')).toBe(2); 34 + }); 35 + 36 + it('should clear all values', () => { 37 + const cache = new LRUCache<string, number>(3); 38 + cache.set('a', 1); 39 + cache.set('b', 2); 40 + cache.clear(); 41 + 42 + expect(cache.get('a')).toBeUndefined(); 43 + expect(cache.get('b')).toBeUndefined(); 44 + }); 45 + 46 + it('should check if key exists', () => { 47 + const cache = new LRUCache<string, number>(3); 48 + cache.set('a', 1); 49 + 50 + expect(cache.has('a')).toBe(true); 51 + expect(cache.has('b')).toBe(false); 52 + }); 53 + }); 54 + 55 + describe('LRU eviction', () => { 56 + it('should evict least recently used when at capacity', () => { 57 + const cache = new LRUCache<string, number>(3); 58 + cache.set('a', 1); 59 + cache.set('b', 2); 60 + cache.set('c', 3); 61 + cache.set('d', 4); // should evict 'a' 62 + 63 + expect(cache.get('a')).toBeUndefined(); 64 + expect(cache.get('b')).toBe(2); 65 + expect(cache.get('c')).toBe(3); 66 + expect(cache.get('d')).toBe(4); 67 + }); 68 + 69 + it('should update LRU order on get', () => { 70 + const cache = new LRUCache<string, number>(3); 71 + cache.set('a', 1); 72 + cache.set('b', 2); 73 + cache.set('c', 3); 74 + 75 + cache.get('a'); // 'a' is now most recently used 76 + cache.set('d', 4); // should evict 'b' (least recently used) 77 + 78 + expect(cache.get('a')).toBe(1); 79 + expect(cache.get('b')).toBeUndefined(); 80 + expect(cache.get('c')).toBe(3); 81 + expect(cache.get('d')).toBe(4); 82 + }); 83 + 84 + it('should update LRU order on set (existing key)', () => { 85 + const cache = new LRUCache<string, number>(3); 86 + cache.set('a', 1); 87 + cache.set('b', 2); 88 + cache.set('c', 3); 89 + 90 + cache.set('a', 10); // 'a' is now most recently used 91 + cache.set('d', 4); // should evict 'b' 92 + 93 + expect(cache.get('a')).toBe(10); 94 + expect(cache.get('b')).toBeUndefined(); 95 + }); 96 + 97 + it('should not update LRU order on peek', () => { 98 + const cache = new LRUCache<string, number>(3); 99 + cache.set('a', 1); 100 + cache.set('b', 2); 101 + cache.set('c', 3); 102 + 103 + cache.peek('a'); // should NOT update LRU order 104 + cache.set('d', 4); // should evict 'a' (still least recently used) 105 + 106 + expect(cache.peek('a')).toBeUndefined(); 107 + expect(cache.get('b')).toBe(2); 108 + }); 109 + }); 110 + 111 + describe('iteration', () => { 112 + it('should iterate keys in LRU order (most to least recent)', () => { 113 + const cache = new LRUCache<string, number>(5); 114 + cache.set('a', 1); 115 + cache.set('b', 2); 116 + cache.set('c', 3); 117 + cache.get('a'); // move 'a' to front 118 + 119 + const keys = [...cache.keys()]; 120 + expect(keys).toEqual(['a', 'c', 'b']); 121 + }); 122 + 123 + it('should iterate values in LRU order', () => { 124 + const cache = new LRUCache<string, number>(5); 125 + cache.set('a', 1); 126 + cache.set('b', 2); 127 + cache.set('c', 3); 128 + 129 + const values = [...cache.values()]; 130 + expect(values).toEqual([3, 2, 1]); 131 + }); 132 + 133 + it('should iterate entries in LRU order', () => { 134 + const cache = new LRUCache<string, number>(5); 135 + cache.set('a', 1); 136 + cache.set('b', 2); 137 + 138 + const entries = [...cache.entries()]; 139 + expect(entries).toEqual([ 140 + ['b', 2], 141 + ['a', 1], 142 + ]); 143 + }); 144 + 145 + it('should be iterable with for-of', () => { 146 + const cache = new LRUCache<string, number>(5); 147 + cache.set('a', 1); 148 + cache.set('b', 2); 149 + 150 + const entries: [string, number][] = []; 151 + for (const entry of cache) { 152 + entries.push(entry); 153 + } 154 + 155 + expect(entries).toEqual([ 156 + ['b', 2], 157 + ['a', 1], 158 + ]); 159 + }); 160 + }); 161 + 162 + describe('edge cases', () => { 163 + it('should handle cache of size 1', () => { 164 + const cache = new LRUCache<string, number>(1); 165 + cache.set('a', 1); 166 + cache.set('b', 2); 167 + 168 + expect(cache.get('a')).toBeUndefined(); 169 + expect(cache.get('b')).toBe(2); 170 + }); 171 + 172 + it('should handle empty cache iteration', () => { 173 + const cache = new LRUCache<string, number>(3); 174 + 175 + expect([...cache.keys()]).toEqual([]); 176 + expect([...cache.values()]).toEqual([]); 177 + expect([...cache.entries()]).toEqual([]); 178 + }); 179 + 180 + it('should report correct size', () => { 181 + const cache = new LRUCache<string, number>(5); 182 + expect(cache.size).toBe(5); 183 + }); 184 + }); 185 + });
+234
packages/oauth/node-client/lib/utils/lru.ts
··· 1 + interface LRUNode<K, V> { 2 + key: K; 3 + value: V; 4 + prev: LRUNode<K, V> | null; 5 + next: LRUNode<K, V> | null; 6 + } 7 + 8 + /** 9 + * a least recently used (LRU) cache with fixed capacity 10 + * evicts the least recently used items when capacity is exceeded 11 + */ 12 + export class LRUCache<K, V> { 13 + readonly #size: number; 14 + #count = 0; 15 + 16 + #map = new Map<K, LRUNode<K, V>>(); 17 + #head: LRUNode<K, V> | null = null; 18 + #tail: LRUNode<K, V> | null = null; 19 + 20 + /** 21 + * creates a new LRU cache with the specified capacity 22 + * @param size the maximum number of items the cache can hold 23 + */ 24 + constructor(size: number) { 25 + this.#size = size; 26 + } 27 + 28 + /** the maximum capacity of the cache */ 29 + get size(): number { 30 + return this.#size; 31 + } 32 + 33 + /** 34 + * gets a value without affecting its position in the cache 35 + * @param key the key to look up 36 + * @returns the value associated with the key, or undefined if not found 37 + */ 38 + peek(key: K): V | undefined { 39 + const node = this.#map.get(key); 40 + if (node === undefined) { 41 + return undefined; 42 + } 43 + 44 + return node.value; 45 + } 46 + 47 + /** 48 + * gets a value and marks it as most recently used 49 + * @param key the key to look up 50 + * @returns the value associated with the key, or undefined if not found 51 + */ 52 + get(key: K): V | undefined { 53 + const node = this.#map.get(key); 54 + if (node === undefined) { 55 + return undefined; 56 + } 57 + 58 + this.#moveToFront(node); 59 + return node.value; 60 + } 61 + 62 + /** 63 + * stores a value for the given key, marking it as most recently used 64 + * evicts the least recently used item if the cache is at capacity 65 + * @param key the key to store 66 + * @param value the value to associate with the key 67 + */ 68 + set(key: K, value: V): void { 69 + { 70 + const existing = this.#map.get(key); 71 + 72 + if (existing !== undefined) { 73 + existing.value = value; 74 + this.#moveToFront(existing); 75 + return; 76 + } 77 + } 78 + 79 + { 80 + const node: LRUNode<K, V> = { key, value, prev: null, next: null }; 81 + this.#map.set(key, node); 82 + this.#addToFront(node); 83 + 84 + this.#count++; 85 + } 86 + 87 + this.#evict(); 88 + } 89 + 90 + /** 91 + * removes a key from the cache 92 + * @param key the key to remove 93 + * @returns true if the key was found and removed, false otherwise 94 + */ 95 + delete(key: K): boolean { 96 + const node = this.#map.get(key); 97 + if (node === undefined) { 98 + return false; 99 + } 100 + 101 + this.#map.delete(key); 102 + this.#removeNode(node); 103 + this.#count--; 104 + return true; 105 + } 106 + 107 + /** 108 + * removes all items from the cache 109 + */ 110 + clear(): void { 111 + this.#map.clear(); 112 + this.#head = null; 113 + this.#tail = null; 114 + this.#count = 0; 115 + } 116 + 117 + /** 118 + * checks if a key exists in the cache 119 + * @param key the key to check 120 + * @returns true if the key exists, false otherwise 121 + */ 122 + has(key: K): boolean { 123 + return this.#map.has(key); 124 + } 125 + 126 + /** 127 + * iterates over the keys in LRU order (most to least recently used) 128 + * @returns iterator of keys 129 + */ 130 + *keys(): IterableIterator<K> { 131 + let current = this.#head; 132 + while (current !== null) { 133 + yield current.key; 134 + current = current.next; 135 + } 136 + } 137 + 138 + /** 139 + * iterates over the values in LRU order (most to least recently used) 140 + * @returns iterator of values 141 + */ 142 + *values(): IterableIterator<V> { 143 + let current = this.#head; 144 + while (current !== null) { 145 + yield current.value; 146 + current = current.next; 147 + } 148 + } 149 + 150 + /** 151 + * iterates over the key-value pairs in LRU order (most to least recently used) 152 + * @returns iterator of [key, value] tuples 153 + */ 154 + *entries(): IterableIterator<[K, V]> { 155 + let current = this.#head; 156 + while (current !== null) { 157 + yield [current.key, current.value]; 158 + current = current.next; 159 + } 160 + } 161 + 162 + [Symbol.iterator](): IterableIterator<[K, V]> { 163 + return this.entries(); 164 + } 165 + 166 + #moveToFront(node: LRUNode<K, V>): void { 167 + if (this.#head === node) { 168 + return; 169 + } 170 + 171 + if (node.prev !== null) { 172 + node.prev.next = node.next; 173 + } 174 + 175 + if (node.next !== null) { 176 + node.next.prev = node.prev; 177 + } else { 178 + this.#tail = node.prev; 179 + } 180 + 181 + node.prev = null; 182 + node.next = this.#head; 183 + 184 + // Safe because this method is only called when head exists 185 + this.#head!.prev = node; 186 + this.#head = node; 187 + } 188 + 189 + #addToFront(node: LRUNode<K, V>): void { 190 + node.next = this.#head; 191 + node.prev = null; 192 + 193 + if (this.#head !== null) { 194 + this.#head.prev = node; 195 + } else { 196 + this.#tail = node; 197 + } 198 + 199 + this.#head = node; 200 + } 201 + 202 + #removeNode(node: LRUNode<K, V>): void { 203 + if (node.prev !== null) { 204 + node.prev.next = node.next; 205 + } else { 206 + this.#head = node.next; 207 + } 208 + 209 + if (node.next !== null) { 210 + node.next.prev = node.prev; 211 + } else { 212 + this.#tail = node.prev; 213 + } 214 + } 215 + 216 + #evict(): void { 217 + const excess = this.#count - this.#size; 218 + if (excess <= 0) { 219 + return; 220 + } 221 + 222 + let current: LRUNode<K, V> = this.#tail!; 223 + 224 + for (let i = 0; i < excess; i++) { 225 + this.#map.delete(current.key); 226 + current = current.prev!; 227 + } 228 + 229 + current.next = null; 230 + this.#tail = current; 231 + 232 + this.#count -= excess; 233 + } 234 + }
+167
packages/oauth/node-client/lib/utils/memory-store.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 + 3 + import { MemoryStore } from './memory-store.js'; 4 + 5 + describe('MemoryStore', () => { 6 + describe('basic operations', () => { 7 + it('should set and get values', () => { 8 + const store = new MemoryStore<string, number>({}); 9 + store.set('a', 1); 10 + store.set('b', 2); 11 + 12 + expect(store.get('a')).toBe(1); 13 + expect(store.get('b')).toBe(2); 14 + }); 15 + 16 + it('should return undefined for missing keys', () => { 17 + const store = new MemoryStore<string, number>({}); 18 + 19 + expect(store.get('nonexistent')).toBeUndefined(); 20 + }); 21 + 22 + it('should delete values', () => { 23 + const store = new MemoryStore<string, number>({}); 24 + store.set('a', 1); 25 + store.delete('a'); 26 + 27 + expect(store.get('a')).toBeUndefined(); 28 + }); 29 + 30 + it('should clear all values', () => { 31 + const store = new MemoryStore<string, number>({}); 32 + store.set('a', 1); 33 + store.set('b', 2); 34 + store.clear(); 35 + 36 + expect(store.get('a')).toBeUndefined(); 37 + expect(store.get('b')).toBeUndefined(); 38 + }); 39 + }); 40 + 41 + describe('TTL expiration', () => { 42 + beforeEach(() => { 43 + vi.useFakeTimers(); 44 + }); 45 + 46 + afterEach(() => { 47 + vi.useRealTimers(); 48 + }); 49 + 50 + it('should expire values after TTL', () => { 51 + const store = new MemoryStore<string, number>({ ttl: 1000 }); 52 + store.set('a', 1); 53 + 54 + expect(store.get('a')).toBe(1); 55 + 56 + vi.advanceTimersByTime(1001); 57 + 58 + expect(store.get('a')).toBeUndefined(); 59 + }); 60 + 61 + it('should not expire values before TTL', () => { 62 + const store = new MemoryStore<string, number>({ ttl: 1000 }); 63 + store.set('a', 1); 64 + 65 + vi.advanceTimersByTime(500); 66 + 67 + expect(store.get('a')).toBe(1); 68 + }); 69 + 70 + it('should refresh TTL on update', () => { 71 + const store = new MemoryStore<string, number>({ ttl: 1000 }); 72 + store.set('a', 1); 73 + 74 + vi.advanceTimersByTime(500); 75 + store.set('a', 2); // refresh TTL 76 + 77 + vi.advanceTimersByTime(700); 78 + expect(store.get('a')).toBe(2); // should still exist 79 + 80 + vi.advanceTimersByTime(400); 81 + expect(store.get('a')).toBeUndefined(); // now expired 82 + }); 83 + }); 84 + 85 + describe('LRU eviction with maxSize', () => { 86 + it('should evict least recently used when at capacity', () => { 87 + const store = new MemoryStore<string, number>({ maxSize: 3 }); 88 + store.set('a', 1); 89 + store.set('b', 2); 90 + store.set('c', 3); 91 + store.set('d', 4); // should evict 'a' 92 + 93 + expect(store.get('a')).toBeUndefined(); 94 + expect(store.get('b')).toBe(2); 95 + expect(store.get('c')).toBe(3); 96 + expect(store.get('d')).toBe(4); 97 + }); 98 + 99 + it('should update LRU order on get', () => { 100 + const store = new MemoryStore<string, number>({ maxSize: 3 }); 101 + store.set('a', 1); 102 + store.set('b', 2); 103 + store.set('c', 3); 104 + 105 + store.get('a'); // 'a' is now most recently used 106 + store.set('d', 4); // should evict 'b' 107 + 108 + expect(store.get('a')).toBe(1); 109 + expect(store.get('b')).toBeUndefined(); 110 + }); 111 + }); 112 + 113 + describe('combined TTL and LRU', () => { 114 + beforeEach(() => { 115 + vi.useFakeTimers(); 116 + }); 117 + 118 + afterEach(() => { 119 + vi.useRealTimers(); 120 + }); 121 + 122 + it('should handle both TTL and LRU together', () => { 123 + const store = new MemoryStore<string, number>({ maxSize: 3, ttl: 1000 }); 124 + store.set('a', 1); 125 + store.set('b', 2); 126 + store.set('c', 3); 127 + 128 + // LRU eviction 129 + store.set('d', 4); 130 + expect(store.get('a')).toBeUndefined(); 131 + 132 + // TTL expiration 133 + vi.advanceTimersByTime(1001); 134 + expect(store.get('b')).toBeUndefined(); 135 + }); 136 + }); 137 + 138 + describe('dispose', () => { 139 + beforeEach(() => { 140 + vi.useFakeTimers(); 141 + }); 142 + 143 + afterEach(() => { 144 + vi.useRealTimers(); 145 + }); 146 + 147 + it('should clear timers on dispose', () => { 148 + const store = new MemoryStore<string, number>({ ttl: 1000, ttlAutopurge: true }); 149 + store.set('a', 1); 150 + 151 + store.dispose(); 152 + 153 + // should not throw or cause issues after dispose 154 + vi.advanceTimersByTime(2000); 155 + }); 156 + 157 + it('should support Symbol.dispose', () => { 158 + const store = new MemoryStore<string, number>({ ttl: 1000, ttlAutopurge: true }); 159 + store.set('a', 1); 160 + 161 + store[Symbol.dispose](); 162 + 163 + // should not throw 164 + vi.advanceTimersByTime(2000); 165 + }); 166 + }); 167 + });
+113
packages/oauth/node-client/lib/utils/memory-store.ts
··· 1 + import type { Store } from './store.js'; 2 + 3 + import { LRUCache } from './lru.js'; 4 + 5 + export interface MemoryStoreOptions { 6 + /** maximum number of items the store can hold */ 7 + maxSize?: number; 8 + /** time-to-live in milliseconds */ 9 + ttl?: number; 10 + /** whether to automatically purge expired entries */ 11 + ttlAutopurge?: boolean; 12 + } 13 + 14 + interface Entry<V> { 15 + value: V; 16 + expiresAt: number; 17 + } 18 + 19 + /** 20 + * in-memory store with optional LRU eviction and TTL expiration. 21 + * 22 + * suitable for development, testing, or single-instance deployments. 23 + * for production with multiple instances, use a shared store (e.g., Redis). 24 + */ 25 + export class MemoryStore<K, V> implements Store<K, V>, Disposable { 26 + #map: LRUCache<K, Entry<V>> | Map<K, Entry<V>>; 27 + 28 + #ttlMs: number; 29 + #ttlAutopurge: boolean; 30 + #ttlTimer: ReturnType<typeof setTimeout> | undefined; 31 + 32 + /** 33 + * creates a new in-memory store. 34 + * 35 + * @param options store configuration 36 + */ 37 + constructor(options: MemoryStoreOptions = {}) { 38 + this.#map = options.maxSize !== undefined ? new LRUCache(options.maxSize) : new Map(); 39 + 40 + this.#ttlMs = options.ttl ?? 0; 41 + this.#ttlAutopurge = options.ttlAutopurge ?? false; 42 + } 43 + 44 + /** @inheritdoc */ 45 + get(key: K): V | undefined { 46 + const entry = this.#map.get(key); 47 + if (entry === undefined) { 48 + return undefined; 49 + } 50 + 51 + if (this.#ttlMs > 0 && Date.now() > entry.expiresAt) { 52 + this.#map.delete(key); 53 + return undefined; 54 + } 55 + 56 + return entry.value; 57 + } 58 + 59 + /** @inheritdoc */ 60 + set(key: K, value: V): void { 61 + this.#map.set(key, { 62 + value, 63 + expiresAt: Date.now() + this.#ttlMs, 64 + }); 65 + 66 + if (this.#ttlAutopurge && this.#ttlTimer === undefined) { 67 + this.#ttlTimer = setTimeout(() => this.#evict(), this.#ttlMs); 68 + } 69 + } 70 + 71 + /** @inheritdoc */ 72 + delete(key: K): void { 73 + this.#map.delete(key); 74 + } 75 + 76 + /** @inheritdoc */ 77 + clear(): void { 78 + this.#map.clear(); 79 + } 80 + 81 + /** 82 + * stops background timers and releases resources. 83 + */ 84 + dispose(): void { 85 + if (this.#ttlTimer !== undefined) { 86 + clearTimeout(this.#ttlTimer); 87 + this.#ttlTimer = undefined; 88 + } 89 + } 90 + 91 + [Symbol.dispose](): void { 92 + this.dispose(); 93 + } 94 + 95 + #evict(): void { 96 + this.#ttlTimer = undefined; 97 + 98 + const now = Date.now(); 99 + let earliest = Infinity; 100 + 101 + for (const [key, { expiresAt }] of this.#map) { 102 + if (now > expiresAt) { 103 + this.#map.delete(key); 104 + } else if (expiresAt < earliest) { 105 + earliest = expiresAt; 106 + } 107 + } 108 + 109 + if (earliest < Infinity) { 110 + this.#ttlTimer = setTimeout(() => this.#evict(), earliest - now); 111 + } 112 + } 113 + }
+43
packages/oauth/node-client/lib/utils/store.ts
··· 1 + import type { Awaitable } from '../types/misc.js'; 2 + 3 + /** options for store get operations */ 4 + export interface GetOptions { 5 + /** abort signal for cancellation */ 6 + signal?: AbortSignal; 7 + } 8 + 9 + /** 10 + * key-value store interface for sessions, states, and caches. 11 + * 12 + * implementations can be synchronous or asynchronous. 13 + */ 14 + export interface Store<K, V> { 15 + /** 16 + * gets a value by key. 17 + * 18 + * @param key lookup key 19 + * @param options get options (e.g., abort signal) 20 + * @returns value if found, undefined otherwise 21 + */ 22 + get(key: K, options?: GetOptions): Awaitable<V | undefined>; 23 + 24 + /** 25 + * sets a value for the given key. 26 + * 27 + * @param key storage key 28 + * @param value value to store 29 + */ 30 + set(key: K, value: V): Awaitable<void>; 31 + 32 + /** 33 + * deletes a value by key. 34 + * 35 + * @param key key to delete 36 + */ 37 + delete(key: K): Awaitable<void>; 38 + 39 + /** 40 + * clears all entries from the store. 41 + */ 42 + clear(): Awaitable<void>; 43 + }
+42
packages/oauth/node-client/package.json
··· 1 + { 2 + "type": "module", 3 + "name": "@atcute/oauth-node-client", 4 + "version": "0.1.0", 5 + "description": "atproto OAuth client for Node.js", 6 + "license": "0BSD", 7 + "repository": { 8 + "url": "https://github.com/mary-ext/atcute", 9 + "directory": "packages/oauth/node-client" 10 + }, 11 + "files": [ 12 + "dist/", 13 + "lib/", 14 + "!lib/**/*.bench.ts", 15 + "!lib/**/*.test.ts" 16 + ], 17 + "exports": { 18 + ".": "./dist/index.js" 19 + }, 20 + "sideEffects": false, 21 + "scripts": { 22 + "build": "tsgo --project tsconfig.build.json", 23 + "test": "vitest", 24 + "prepublish": "rm -rf dist; pnpm run build" 25 + }, 26 + "dependencies": { 27 + "@atcute/client": "workspace:^", 28 + "@atcute/identity": "workspace:^", 29 + "@atcute/identity-resolver": "workspace:^", 30 + "@atcute/lexicons": "workspace:^", 31 + "@atcute/multibase": "workspace:^", 32 + "@atcute/uint8array": "workspace:^", 33 + "@atcute/util-fetch": "workspace:^", 34 + "@badrap/valita": "^0.4.6", 35 + "jose": "^6.1.3", 36 + "nanoid": "^5.1.6" 37 + }, 38 + "devDependencies": { 39 + "@types/node": "^22.19.1", 40 + "vitest": "^4.0.15" 41 + } 42 + }
+4
packages/oauth/node-client/tsconfig.build.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "exclude": ["**/*.test.ts"] 4 + }
+24
packages/oauth/node-client/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "types": [], 4 + "outDir": "dist/", 5 + "esModuleInterop": true, 6 + "skipLibCheck": true, 7 + "target": "ESNext", 8 + "allowJs": true, 9 + "resolveJsonModule": true, 10 + "moduleDetection": "force", 11 + "isolatedModules": true, 12 + "verbatimModuleSyntax": true, 13 + "strict": true, 14 + "noImplicitOverride": true, 15 + "noUnusedLocals": true, 16 + "noUnusedParameters": true, 17 + "noFallthroughCasesInSwitch": true, 18 + "module": "NodeNext", 19 + "sourceMap": true, 20 + "declaration": true, 21 + "declarationMap": true 22 + }, 23 + "include": ["lib"] 24 + }
+7
packages/oauth/node-client/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + 3 + export default defineConfig({ 4 + test: { 5 + include: ['lib/**/*.test.ts'], 6 + }, 7 + });
+241 -6
pnpm-lock.yaml
··· 673 673 specifier: ^5.1.6 674 674 version: 5.1.6 675 675 676 + packages/oauth/node-client: 677 + dependencies: 678 + '@atcute/client': 679 + specifier: workspace:^ 680 + version: link:../../clients/client 681 + '@atcute/identity': 682 + specifier: workspace:^ 683 + version: link:../../identity/identity 684 + '@atcute/identity-resolver': 685 + specifier: workspace:^ 686 + version: link:../../identity/identity-resolver 687 + '@atcute/lexicons': 688 + specifier: workspace:^ 689 + version: link:../../lexicons/lexicons 690 + '@atcute/multibase': 691 + specifier: workspace:^ 692 + version: link:../../utilities/multibase 693 + '@atcute/uint8array': 694 + specifier: workspace:^ 695 + version: link:../../misc/uint8array 696 + '@atcute/util-fetch': 697 + specifier: workspace:^ 698 + version: link:../../misc/util-fetch 699 + '@badrap/valita': 700 + specifier: ^0.4.6 701 + version: 0.4.6 702 + jose: 703 + specifier: ^6.1.3 704 + version: 6.1.3 705 + nanoid: 706 + specifier: ^5.1.6 707 + version: 5.1.6 708 + devDependencies: 709 + '@types/node': 710 + specifier: ^22.19.1 711 + version: 22.19.1 712 + vitest: 713 + specifier: ^4.0.15 714 + version: 4.0.15(@types/node@22.19.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0) 715 + 716 + packages/oauth/node-client-example: 717 + dependencies: 718 + '@atcute/atproto': 719 + specifier: workspace:* 720 + version: link:../../definitions/atproto 721 + '@atcute/client': 722 + specifier: workspace:* 723 + version: link:../../clients/client 724 + '@atcute/identity-resolver': 725 + specifier: workspace:* 726 + version: link:../../identity/identity-resolver 727 + '@atcute/identity-resolver-node': 728 + specifier: workspace:^ 729 + version: link:../../identity/identity-resolver-node 730 + '@atcute/lexicons': 731 + specifier: workspace:* 732 + version: link:../../lexicons/lexicons 733 + '@atcute/oauth-node-client': 734 + specifier: workspace:* 735 + version: link:../node-client 736 + hono: 737 + specifier: ^4.11.0 738 + version: 4.11.0 739 + nanoid: 740 + specifier: ^5.1.6 741 + version: 5.1.6 742 + devDependencies: 743 + '@types/bun': 744 + specifier: latest 745 + version: 1.3.4 746 + 676 747 packages/servers/xrpc-server: 677 748 dependencies: 678 749 '@atcute/cbor': ··· 775 846 version: link:../../utilities/cbor 776 847 '@hono/node-server': 777 848 specifier: ^1.19.6 778 - version: 1.19.6(hono@4.10.2) 849 + version: 1.19.6(hono@4.11.0) 779 850 '@types/node': 780 851 specifier: ^22.19.1 781 852 version: 22.19.1 ··· 2154 2225 '@types/bun@1.3.3': 2155 2226 resolution: {integrity: sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==} 2156 2227 2228 + '@types/bun@1.3.4': 2229 + resolution: {integrity: sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA==} 2230 + 2157 2231 '@types/chai@5.2.3': 2158 2232 resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} 2159 2233 ··· 2251 2325 '@vitest/expect@4.0.14': 2252 2326 resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} 2253 2327 2328 + '@vitest/expect@4.0.15': 2329 + resolution: {integrity: sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==} 2330 + 2254 2331 '@vitest/mocker@4.0.14': 2255 2332 resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} 2256 2333 peerDependencies: ··· 2262 2339 vite: 2263 2340 optional: true 2264 2341 2342 + '@vitest/mocker@4.0.15': 2343 + resolution: {integrity: sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==} 2344 + peerDependencies: 2345 + msw: ^2.4.9 2346 + vite: ^6.0.0 || ^7.0.0-0 2347 + peerDependenciesMeta: 2348 + msw: 2349 + optional: true 2350 + vite: 2351 + optional: true 2352 + 2265 2353 '@vitest/pretty-format@4.0.14': 2266 2354 resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==} 2355 + 2356 + '@vitest/pretty-format@4.0.15': 2357 + resolution: {integrity: sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==} 2267 2358 2268 2359 '@vitest/runner@4.0.14': 2269 2360 resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} 2270 2361 2362 + '@vitest/runner@4.0.15': 2363 + resolution: {integrity: sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==} 2364 + 2271 2365 '@vitest/snapshot@4.0.14': 2272 2366 resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} 2367 + 2368 + '@vitest/snapshot@4.0.15': 2369 + resolution: {integrity: sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==} 2273 2370 2274 2371 '@vitest/spy@4.0.14': 2275 2372 resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} 2276 2373 2374 + '@vitest/spy@4.0.15': 2375 + resolution: {integrity: sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==} 2376 + 2277 2377 '@vitest/utils@4.0.14': 2278 2378 resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==} 2379 + 2380 + '@vitest/utils@4.0.15': 2381 + resolution: {integrity: sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==} 2279 2382 2280 2383 abort-controller@3.0.0: 2281 2384 resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} ··· 2395 2498 2396 2499 bun-types@1.3.3: 2397 2500 resolution: {integrity: sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==} 2501 + 2502 + bun-types@1.3.4: 2503 + resolution: {integrity: sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ==} 2398 2504 2399 2505 bytes@3.1.2: 2400 2506 resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} ··· 2856 2962 hmac-drbg@1.0.1: 2857 2963 resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} 2858 2964 2859 - hono@4.10.2: 2860 - resolution: {integrity: sha512-p6fyzl+mQo6uhESLxbF5WlBOAJMDh36PljwlKtP5V1v09NxlqGru3ShK+4wKhSuhuYf8qxMmrivHOa/M7q0sMg==} 2965 + hono@4.11.0: 2966 + resolution: {integrity: sha512-Jg8uZzN2ul8/qlyid5FO8O624F3AK0wKtkgoeEON1qBum1rM1itYBxoMKu/1SPJC7F1+xlIZsJMmc4HHhJ1AWg==} 2861 2967 engines: {node: '>=16.9.0'} 2862 2968 2863 2969 html-escaper@2.0.2: ··· 2978 3084 2979 3085 jose@5.10.0: 2980 3086 resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} 3087 + 3088 + jose@6.1.3: 3089 + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} 2981 3090 2982 3091 js-tokens@9.0.1: 2983 3092 resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} ··· 3661 3770 tinyexec@0.3.2: 3662 3771 resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} 3663 3772 3773 + tinyexec@1.0.2: 3774 + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} 3775 + engines: {node: '>=18'} 3776 + 3664 3777 tinyglobby@0.2.15: 3665 3778 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 3666 3779 engines: {node: '>=12.0.0'} ··· 3852 3965 jsdom: 3853 3966 optional: true 3854 3967 3968 + vitest@4.0.15: 3969 + resolution: {integrity: sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==} 3970 + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} 3971 + hasBin: true 3972 + peerDependencies: 3973 + '@edge-runtime/vm': '*' 3974 + '@opentelemetry/api': ^1.9.0 3975 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 3976 + '@vitest/browser-playwright': 4.0.15 3977 + '@vitest/browser-preview': 4.0.15 3978 + '@vitest/browser-webdriverio': 4.0.15 3979 + '@vitest/ui': 4.0.15 3980 + happy-dom: '*' 3981 + jsdom: '*' 3982 + peerDependenciesMeta: 3983 + '@edge-runtime/vm': 3984 + optional: true 3985 + '@opentelemetry/api': 3986 + optional: true 3987 + '@types/node': 3988 + optional: true 3989 + '@vitest/browser-playwright': 3990 + optional: true 3991 + '@vitest/browser-preview': 3992 + optional: true 3993 + '@vitest/browser-webdriverio': 3994 + optional: true 3995 + '@vitest/ui': 3996 + optional: true 3997 + happy-dom: 3998 + optional: true 3999 + jsdom: 4000 + optional: true 4001 + 3855 4002 which@2.0.2: 3856 4003 resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 3857 4004 engines: {node: '>= 8'} ··· 5208 5355 5209 5356 '@hapi/hoek@11.0.7': {} 5210 5357 5211 - '@hono/node-server@1.19.6(hono@4.10.2)': 5358 + '@hono/node-server@1.19.6(hono@4.11.0)': 5212 5359 dependencies: 5213 - hono: 4.10.2 5360 + hono: 4.11.0 5214 5361 5215 5362 '@img/sharp-darwin-arm64@0.33.5': 5216 5363 optionalDependencies: ··· 5799 5946 dependencies: 5800 5947 bun-types: 1.3.3 5801 5948 5949 + '@types/bun@1.3.4': 5950 + dependencies: 5951 + bun-types: 1.3.4 5952 + 5802 5953 '@types/chai@5.2.3': 5803 5954 dependencies: 5804 5955 '@types/deep-eql': 4.0.2 ··· 5975 6126 chai: 6.2.1 5976 6127 tinyrainbow: 3.0.3 5977 6128 6129 + '@vitest/expect@4.0.15': 6130 + dependencies: 6131 + '@standard-schema/spec': 1.0.0 6132 + '@types/chai': 5.2.3 6133 + '@vitest/spy': 4.0.15 6134 + '@vitest/utils': 4.0.15 6135 + chai: 6.2.1 6136 + tinyrainbow: 3.0.3 6137 + 5978 6138 '@vitest/mocker@4.0.14(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0))': 5979 6139 dependencies: 5980 6140 '@vitest/spy': 4.0.14 ··· 5991 6151 optionalDependencies: 5992 6152 vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0) 5993 6153 6154 + '@vitest/mocker@4.0.15(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0))': 6155 + dependencies: 6156 + '@vitest/spy': 4.0.15 6157 + estree-walker: 3.0.3 6158 + magic-string: 0.30.21 6159 + optionalDependencies: 6160 + vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0) 6161 + 5994 6162 '@vitest/pretty-format@4.0.14': 5995 6163 dependencies: 5996 6164 tinyrainbow: 3.0.3 5997 6165 6166 + '@vitest/pretty-format@4.0.15': 6167 + dependencies: 6168 + tinyrainbow: 3.0.3 6169 + 5998 6170 '@vitest/runner@4.0.14': 5999 6171 dependencies: 6000 6172 '@vitest/utils': 4.0.14 6001 6173 pathe: 2.0.3 6002 6174 6175 + '@vitest/runner@4.0.15': 6176 + dependencies: 6177 + '@vitest/utils': 4.0.15 6178 + pathe: 2.0.3 6179 + 6003 6180 '@vitest/snapshot@4.0.14': 6004 6181 dependencies: 6005 6182 '@vitest/pretty-format': 4.0.14 6183 + magic-string: 0.30.21 6184 + pathe: 2.0.3 6185 + 6186 + '@vitest/snapshot@4.0.15': 6187 + dependencies: 6188 + '@vitest/pretty-format': 4.0.15 6006 6189 magic-string: 0.30.21 6007 6190 pathe: 2.0.3 6008 6191 6009 6192 '@vitest/spy@4.0.14': {} 6010 6193 6194 + '@vitest/spy@4.0.15': {} 6195 + 6011 6196 '@vitest/utils@4.0.14': 6012 6197 dependencies: 6013 6198 '@vitest/pretty-format': 4.0.14 6199 + tinyrainbow: 3.0.3 6200 + 6201 + '@vitest/utils@4.0.15': 6202 + dependencies: 6203 + '@vitest/pretty-format': 4.0.15 6014 6204 tinyrainbow: 3.0.3 6015 6205 6016 6206 abort-controller@3.0.0: ··· 6148 6338 dependencies: 6149 6339 '@types/node': 22.19.1 6150 6340 6341 + bun-types@1.3.4: 6342 + dependencies: 6343 + '@types/node': 22.19.1 6344 + 6151 6345 bytes@3.1.2: {} 6152 6346 6153 6347 call-bind-apply-helpers@1.0.2: ··· 6641 6835 minimalistic-assert: 1.0.1 6642 6836 minimalistic-crypto-utils: 1.0.1 6643 6837 6644 - hono@4.10.2: {} 6838 + hono@4.11.0: {} 6645 6839 6646 6840 html-escaper@2.0.2: {} 6647 6841 ··· 6771 6965 optional: true 6772 6966 6773 6967 jose@5.10.0: {} 6968 + 6969 + jose@6.1.3: {} 6774 6970 6775 6971 js-tokens@9.0.1: {} 6776 6972 ··· 7455 7651 tinybench@2.9.0: {} 7456 7652 7457 7653 tinyexec@0.3.2: {} 7654 + 7655 + tinyexec@1.0.2: {} 7458 7656 7459 7657 tinyglobby@0.2.15: 7460 7658 dependencies: ··· 7638 7836 optionalDependencies: 7639 7837 '@types/node': 24.10.1 7640 7838 '@vitest/browser-playwright': 4.0.14(playwright@1.57.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0))(vitest@4.0.14) 7839 + transitivePeerDependencies: 7840 + - jiti 7841 + - less 7842 + - lightningcss 7843 + - msw 7844 + - sass 7845 + - sass-embedded 7846 + - stylus 7847 + - sugarss 7848 + - terser 7849 + - tsx 7850 + - yaml 7851 + 7852 + vitest@4.0.15(@types/node@22.19.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0): 7853 + dependencies: 7854 + '@vitest/expect': 4.0.15 7855 + '@vitest/mocker': 4.0.15(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0)) 7856 + '@vitest/pretty-format': 4.0.15 7857 + '@vitest/runner': 4.0.15 7858 + '@vitest/snapshot': 4.0.15 7859 + '@vitest/spy': 4.0.15 7860 + '@vitest/utils': 4.0.15 7861 + es-module-lexer: 1.7.0 7862 + expect-type: 1.2.2 7863 + magic-string: 0.30.21 7864 + obug: 2.1.1 7865 + pathe: 2.0.3 7866 + picomatch: 4.0.3 7867 + std-env: 3.10.0 7868 + tinybench: 2.9.0 7869 + tinyexec: 1.0.2 7870 + tinyglobby: 0.2.15 7871 + tinyrainbow: 3.0.3 7872 + vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0) 7873 + why-is-node-running: 2.3.0 7874 + optionalDependencies: 7875 + '@types/node': 22.19.1 7641 7876 transitivePeerDependencies: 7642 7877 - jiti 7643 7878 - less