A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

feat(oauth): implement OAuth 2.0 authorization server

Complete OAuth 2.0 implementation for AT Protocol with:

- Discovery endpoints (AS metadata, protected resource, JWKS)
- Pushed Authorization Requests (PAR) with DPoP validation
- Authorization endpoint with consent UI (dark theme)
- Token endpoint (authorization_code + refresh_token grants)
- Token revocation (RFC 7009)
- DPoP proof validation and token binding
- PKCE with S256 code challenge
- Client metadata fetching and validation
- Loopback client support for development

Security features:
- DPoP JTI tracking to prevent replay attacks
- Timing-safe password comparison
- 24-hour maximum token lifetime
- Automatic cleanup of expired authorization requests

Also includes comprehensive e2e tests and JSDoc types.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+2 -6
README.md
··· 14 14 - [x] Relay notification (requestCrawl) 15 15 - [x] Single or multi-user (each DID gets isolated storage, no self-service signup yet) 16 16 - [x] Blob storage (uploadBlob, getBlob, listBlobs) 17 + - [x] OAuth 2.0 (PAR, authorization code + PKCE, DPoP-bound tokens, refresh, revoke) 17 18 - [ ] deleteSession (logout) 18 19 - [ ] updateHandle 19 20 - [ ] importRepo 20 - - [ ] OAuth 21 21 - [ ] Account management (createAccount, deleteAccount) 22 22 - [ ] Email verification 23 23 - [ ] Invite codes ··· 27 27 ## Prerequisites 28 28 29 29 - Node.js 18+ 30 - - [shfmt](https://github.com/mvdan/sh) (optional, for `npm run format`) 31 - ```bash 32 - brew install shfmt 33 - ``` 34 30 35 31 ## Quick Start 36 32 ··· 50 46 For local development, create `.dev.vars`: 51 47 52 48 ``` 53 - PDS_PASSWORD=your-password 49 + PDS_PASSWORD=your-password # Used for legacy auth and OAuth consent 54 50 JWT_SECRET=your-secret 55 51 RELAY_HOST=https://bsky.network # optional 56 52 ```
+1283
docs/plans/2026-01-07-oauth-implementation.md
··· 1 + # OAuth Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add full AT Protocol OAuth support (PAR, DPoP, PKCE, authorization code flow) to pds.js while maintaining zero external dependencies. 6 + 7 + **Architecture:** Extend the existing single-file pds.js with OAuth endpoints. Store authorization requests and tokens in SQLite. Use Web Crypto APIs for all cryptographic operations. Minimal server-rendered HTML for consent UI. 8 + 9 + **Tech Stack:** JavaScript (Cloudflare Workers), SQLite (Durable Objects), Web Crypto API, P-256/ES256 signatures. 10 + 11 + --- 12 + 13 + ## Task 1: Add OAuth Database Tables 14 + 15 + **Files:** 16 + - Modify: `src/pds.js` 17 + 18 + **Step 1: Add tables to initializeDatabase** 19 + 20 + In `src/pds.js`, add to the `initializeDatabase` function after existing table creation: 21 + 22 + ```javascript 23 + // OAuth authorization requests (from PAR) 24 + await sql` 25 + CREATE TABLE IF NOT EXISTS authorization_requests ( 26 + id TEXT PRIMARY KEY, 27 + client_id TEXT NOT NULL, 28 + client_metadata TEXT NOT NULL, 29 + parameters TEXT NOT NULL, 30 + code TEXT, 31 + code_challenge TEXT, 32 + code_challenge_method TEXT, 33 + dpop_jkt TEXT, 34 + did TEXT, 35 + expires_at TEXT NOT NULL, 36 + created_at TEXT NOT NULL 37 + ) 38 + `; 39 + 40 + await sql` 41 + CREATE INDEX IF NOT EXISTS idx_authorization_requests_code 42 + ON authorization_requests(code) WHERE code IS NOT NULL 43 + `; 44 + 45 + // OAuth tokens 46 + await sql` 47 + CREATE TABLE IF NOT EXISTS tokens ( 48 + id INTEGER PRIMARY KEY AUTOINCREMENT, 49 + token_id TEXT UNIQUE NOT NULL, 50 + did TEXT NOT NULL, 51 + client_id TEXT NOT NULL, 52 + scope TEXT, 53 + dpop_jkt TEXT, 54 + expires_at TEXT NOT NULL, 55 + refresh_token TEXT UNIQUE, 56 + created_at TEXT NOT NULL, 57 + updated_at TEXT NOT NULL 58 + ) 59 + `; 60 + 61 + await sql` 62 + CREATE INDEX IF NOT EXISTS idx_tokens_did ON tokens(did) 63 + `; 64 + ``` 65 + 66 + **Step 2: Commit** 67 + 68 + ```bash 69 + git add src/pds.js 70 + git commit -m "feat(oauth): add authorization_requests and tokens tables" 71 + ``` 72 + 73 + --- 74 + 75 + ## Task 2: Implement JWK Thumbprint 76 + 77 + **Files:** 78 + - Modify: `src/pds.js` 79 + - Test: `test/pds.test.js` 80 + 81 + **Step 1: Add unit test** 82 + 83 + Add to `test/pds.test.js` imports and test: 84 + 85 + ```javascript 86 + import { 87 + // ... existing imports ... 88 + computeJwkThumbprint, 89 + } from '../src/pds.js'; 90 + 91 + describe('JWK Thumbprint', () => { 92 + test('computes deterministic thumbprint for EC key', async () => { 93 + // Test vector: known JWK and its expected thumbprint 94 + const jwk = { 95 + kty: 'EC', 96 + crv: 'P-256', 97 + x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', 98 + y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ' 99 + }; 100 + 101 + const jkt1 = await computeJwkThumbprint(jwk); 102 + const jkt2 = await computeJwkThumbprint(jwk); 103 + 104 + // Thumbprint must be deterministic 105 + assert.strictEqual(jkt1, jkt2); 106 + // Must be base64url-encoded SHA-256 (43 chars) 107 + assert.strictEqual(jkt1.length, 43); 108 + // Must only contain base64url characters 109 + assert.match(jkt1, /^[A-Za-z0-9_-]+$/); 110 + }); 111 + 112 + test('produces different thumbprints for different keys', async () => { 113 + const jwk1 = { kty: 'EC', crv: 'P-256', x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ' }; 114 + const jwk2 = { kty: 'EC', crv: 'P-256', x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0' }; 115 + 116 + const jkt1 = await computeJwkThumbprint(jwk1); 117 + const jkt2 = await computeJwkThumbprint(jwk2); 118 + 119 + assert.notStrictEqual(jkt1, jkt2); 120 + }); 121 + }); 122 + ``` 123 + 124 + **Step 2: Implement and export** 125 + 126 + Add to `src/pds.js`: 127 + 128 + ```javascript 129 + /** 130 + * Compute JWK thumbprint (SHA-256) per RFC 7638. 131 + * Creates a canonical JSON representation of EC key required members 132 + * and returns the base64url-encoded SHA-256 hash. 133 + * @param {{ kty: string, crv: string, x: string, y: string }} jwk - The EC public key in JWK format 134 + * @returns {Promise<string>} The base64url-encoded thumbprint 135 + */ 136 + export async function computeJwkThumbprint(jwk) { 137 + // RFC 7638: members must be in lexicographic order 138 + const thumbprintInput = JSON.stringify({ 139 + crv: jwk.crv, 140 + kty: jwk.kty, 141 + x: jwk.x, 142 + y: jwk.y 143 + }); 144 + const hash = await crypto.subtle.digest( 145 + 'SHA-256', 146 + new TextEncoder().encode(thumbprintInput) 147 + ); 148 + return base64UrlEncode(new Uint8Array(hash)); 149 + } 150 + ``` 151 + 152 + **Step 3: Run tests and commit** 153 + 154 + ```bash 155 + npm test 156 + git add src/pds.js test/pds.test.js 157 + git commit -m "feat(oauth): implement JWK thumbprint computation" 158 + ``` 159 + 160 + --- 161 + 162 + ## Task 3: Implement Client Metadata Validation 163 + 164 + **Files:** 165 + - Modify: `src/pds.js` 166 + - Test: `test/pds.test.js` 167 + 168 + **Step 1: Add unit tests** 169 + 170 + ```javascript 171 + import { 172 + // ... existing imports ... 173 + isLoopbackClient, 174 + getLoopbackClientMetadata, 175 + validateClientMetadata, 176 + } from '../src/pds.js'; 177 + 178 + describe('Client Metadata', () => { 179 + test('isLoopbackClient detects localhost', () => { 180 + assert.strictEqual(isLoopbackClient('http://localhost:8080'), true); 181 + assert.strictEqual(isLoopbackClient('http://127.0.0.1:3000'), true); 182 + assert.strictEqual(isLoopbackClient('https://example.com'), false); 183 + }); 184 + 185 + test('getLoopbackClientMetadata returns permissive defaults', () => { 186 + const metadata = getLoopbackClientMetadata('http://localhost:8080'); 187 + assert.strictEqual(metadata.client_id, 'http://localhost:8080'); 188 + assert.ok(metadata.grant_types.includes('authorization_code')); 189 + assert.strictEqual(metadata.dpop_bound_access_tokens, true); 190 + }); 191 + 192 + test('validateClientMetadata rejects mismatched client_id', () => { 193 + const metadata = { 194 + client_id: 'https://other.com/metadata.json', 195 + redirect_uris: ['https://example.com/callback'], 196 + grant_types: ['authorization_code'], 197 + response_types: ['code'] 198 + }; 199 + assert.throws( 200 + () => validateClientMetadata(metadata, 'https://example.com/metadata.json'), 201 + /client_id mismatch/ 202 + ); 203 + }); 204 + }); 205 + ``` 206 + 207 + **Step 2: Implement functions** 208 + 209 + ```javascript 210 + /** 211 + * Check if a client_id represents a loopback client (localhost development). 212 + * Loopback clients are allowed without pre-registration per AT Protocol OAuth spec. 213 + * @param {string} clientId - The client_id to check 214 + * @returns {boolean} True if the client_id is a loopback address 215 + */ 216 + export function isLoopbackClient(clientId) { 217 + try { 218 + const url = new URL(clientId); 219 + const host = url.hostname.toLowerCase(); 220 + return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'; 221 + } catch { 222 + return false; 223 + } 224 + } 225 + 226 + /** 227 + * @typedef {Object} ClientMetadata 228 + * @property {string} client_id - The client identifier (must match the URL used to fetch metadata) 229 + * @property {string} [client_name] - Human-readable client name 230 + * @property {string[]} redirect_uris - Allowed redirect URIs 231 + * @property {string[]} grant_types - Supported grant types 232 + * @property {string[]} response_types - Supported response types 233 + * @property {string} [token_endpoint_auth_method] - Token endpoint auth method 234 + * @property {boolean} [dpop_bound_access_tokens] - Whether client requires DPoP-bound tokens 235 + * @property {string} [scope] - Default scope 236 + */ 237 + 238 + /** 239 + * Generate permissive client metadata for a loopback client. 240 + * @param {string} clientId - The loopback client_id 241 + * @returns {ClientMetadata} Generated client metadata 242 + */ 243 + export function getLoopbackClientMetadata(clientId) { 244 + return { 245 + client_id: clientId, 246 + client_name: 'Loopback Client', 247 + redirect_uris: [clientId], 248 + grant_types: ['authorization_code', 'refresh_token'], 249 + response_types: ['code'], 250 + token_endpoint_auth_method: 'none', 251 + dpop_bound_access_tokens: true, 252 + scope: 'atproto' 253 + }; 254 + } 255 + 256 + /** 257 + * Validate client metadata against AT Protocol OAuth requirements. 258 + * @param {ClientMetadata} metadata - The client metadata to validate 259 + * @param {string} expectedClientId - The expected client_id (the URL used to fetch metadata) 260 + * @throws {Error} If validation fails 261 + */ 262 + export function validateClientMetadata(metadata, expectedClientId) { 263 + if (!metadata.client_id) throw new Error('client_id is required'); 264 + if (metadata.client_id !== expectedClientId) throw new Error('client_id mismatch'); 265 + if (!Array.isArray(metadata.redirect_uris) || metadata.redirect_uris.length === 0) { 266 + throw new Error('redirect_uris is required'); 267 + } 268 + if (!metadata.grant_types?.includes('authorization_code')) { 269 + throw new Error('grant_types must include authorization_code'); 270 + } 271 + } 272 + 273 + /** @type {Map<string, { metadata: ClientMetadata, expiresAt: number }>} */ 274 + const clientMetadataCache = new Map(); 275 + 276 + /** 277 + * Fetch and validate client metadata from a client_id URL. 278 + * Caches results for 10 minutes. Loopback clients return synthetic metadata. 279 + * @param {string} clientId - The client_id (URL to fetch metadata from) 280 + * @returns {Promise<ClientMetadata>} The validated client metadata 281 + * @throws {Error} If fetching or validation fails 282 + */ 283 + async function getClientMetadata(clientId) { 284 + const cached = clientMetadataCache.get(clientId); 285 + if (cached && Date.now() < cached.expiresAt) return cached.metadata; 286 + 287 + if (isLoopbackClient(clientId)) { 288 + const metadata = getLoopbackClientMetadata(clientId); 289 + clientMetadataCache.set(clientId, { metadata, expiresAt: Date.now() + 600000 }); 290 + return metadata; 291 + } 292 + 293 + const response = await fetch(clientId, { headers: { 'Accept': 'application/json' } }); 294 + if (!response.ok) throw new Error(`Failed to fetch client metadata: ${response.status}`); 295 + 296 + const metadata = await response.json(); 297 + validateClientMetadata(metadata, clientId); 298 + clientMetadataCache.set(clientId, { metadata, expiresAt: Date.now() + 600000 }); 299 + return metadata; 300 + } 301 + ``` 302 + 303 + **Step 3: Run tests and commit** 304 + 305 + ```bash 306 + npm test 307 + git add src/pds.js test/pds.test.js 308 + git commit -m "feat(oauth): implement client metadata fetching and validation" 309 + ``` 310 + 311 + --- 312 + 313 + ## Task 4: Implement DPoP Proof Parsing 314 + 315 + **Files:** 316 + - Modify: `src/pds.js` 317 + 318 + **Step 1: Implement parseDpopProof** 319 + 320 + ```javascript 321 + /** 322 + * @typedef {Object} DpopProofResult 323 + * @property {string} jkt - The JWK thumbprint of the DPoP key 324 + * @property {string} jti - The unique identifier from the DPoP proof 325 + * @property {{ kty: string, crv: string, x: string, y: string }} jwk - The public key from the proof 326 + */ 327 + 328 + /** 329 + * Parse and validate a DPoP proof JWT. 330 + * Verifies the signature, checks claims (htm, htu, iat, jti), and optionally 331 + * validates key binding (expectedJkt) and access token hash (ath). 332 + * @param {string} proof - The DPoP proof JWT 333 + * @param {string} method - The expected HTTP method (htm claim) 334 + * @param {string} url - The expected request URL (htu claim) 335 + * @param {string|null} [expectedJkt=null] - If provided, verify the key matches this thumbprint 336 + * @param {string|null} [accessToken=null] - If provided, verify the ath claim matches this token's hash 337 + * @returns {Promise<DpopProofResult>} The parsed proof with jkt, jti, and jwk 338 + * @throws {Error} If validation fails 339 + */ 340 + async function parseDpopProof(proof, method, url, expectedJkt = null, accessToken = null) { 341 + const parts = proof.split('.'); 342 + if (parts.length !== 3) throw new Error('Invalid DPoP proof format'); 343 + 344 + const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))); 345 + const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))); 346 + 347 + if (header.typ !== 'dpop+jwt') throw new Error('DPoP proof must have typ dpop+jwt'); 348 + if (header.alg !== 'ES256') throw new Error('DPoP proof must use ES256'); 349 + if (!header.jwk || header.jwk.kty !== 'EC') throw new Error('DPoP proof must contain EC key'); 350 + 351 + // Verify signature 352 + const publicKey = await crypto.subtle.importKey( 353 + 'jwk', header.jwk, 354 + { name: 'ECDSA', namedCurve: 'P-256' }, 355 + false, ['verify'] 356 + ); 357 + 358 + const signatureInput = new TextEncoder().encode(parts[0] + '.' + parts[1]); 359 + const signature = base64UrlDecode(parts[2]); 360 + const derSignature = compactSignatureToDer(signature); 361 + 362 + const valid = await crypto.subtle.verify( 363 + { name: 'ECDSA', hash: 'SHA-256' }, 364 + publicKey, derSignature, signatureInput 365 + ); 366 + if (!valid) throw new Error('DPoP proof signature invalid'); 367 + 368 + // Validate claims 369 + if (payload.htm !== method) throw new Error('DPoP htm mismatch'); 370 + 371 + const normalizeUrl = (u) => u.replace(/\/$/, '').split('?')[0].toLowerCase(); 372 + if (normalizeUrl(payload.htu) !== normalizeUrl(url)) throw new Error('DPoP htu mismatch'); 373 + 374 + const now = Math.floor(Date.now() / 1000); 375 + if (!payload.iat || payload.iat > now + 60 || payload.iat < now - 300) { 376 + throw new Error('DPoP proof expired or invalid iat'); 377 + } 378 + 379 + if (!payload.jti) throw new Error('DPoP proof missing jti'); 380 + 381 + const jkt = await computeJwkThumbprint(header.jwk); 382 + if (expectedJkt && jkt !== expectedJkt) throw new Error('DPoP key mismatch'); 383 + 384 + if (accessToken) { 385 + const tokenHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken)); 386 + const expectedAth = base64UrlEncode(new Uint8Array(tokenHash)); 387 + if (payload.ath !== expectedAth) throw new Error('DPoP ath mismatch'); 388 + } 389 + 390 + return { jkt, jti: payload.jti, jwk: header.jwk }; 391 + } 392 + 393 + /** 394 + * Convert a compact (r||s) ECDSA signature to DER format for Web Crypto API. 395 + * @param {Uint8Array} compact - The 64-byte compact signature (32 bytes r + 32 bytes s) 396 + * @returns {Uint8Array} The DER-encoded signature 397 + */ 398 + function compactSignatureToDer(compact) { 399 + const r = compact.slice(0, 32); 400 + const s = compact.slice(32, 64); 401 + 402 + /** 403 + * @param {Uint8Array} bytes 404 + * @returns {Uint8Array} 405 + */ 406 + function encodeInt(bytes) { 407 + let i = 0; 408 + while (i < bytes.length - 1 && bytes[i] === 0 && !(bytes[i + 1] & 0x80)) i++; 409 + const trimmed = bytes.slice(i); 410 + if (trimmed[0] & 0x80) return new Uint8Array([0x02, trimmed.length + 1, 0, ...trimmed]); 411 + return new Uint8Array([0x02, trimmed.length, ...trimmed]); 412 + } 413 + 414 + const rDer = encodeInt(r); 415 + const sDer = encodeInt(s); 416 + return new Uint8Array([0x30, rDer.length + sDer.length, ...rDer, ...sDer]); 417 + } 418 + ``` 419 + 420 + **Step 2: Commit** 421 + 422 + ```bash 423 + git add src/pds.js 424 + git commit -m "feat(oauth): implement DPoP proof parsing" 425 + ``` 426 + 427 + --- 428 + 429 + ## Task 5: Add OAuth Discovery Endpoints 430 + 431 + **Files:** 432 + - Modify: `src/pds.js` 433 + 434 + **Step 1: Add endpoints to handleRequest** 435 + 436 + ```javascript 437 + // OAuth Authorization Server Metadata 438 + if (path === '/.well-known/oauth-authorization-server' && method === 'GET') { 439 + const issuer = `${url.protocol}//${url.host}`; 440 + return json({ 441 + issuer, 442 + authorization_endpoint: `${issuer}/oauth/authorize`, 443 + token_endpoint: `${issuer}/oauth/token`, 444 + revocation_endpoint: `${issuer}/oauth/revoke`, 445 + pushed_authorization_request_endpoint: `${issuer}/oauth/par`, 446 + jwks_uri: `${issuer}/oauth/jwks`, 447 + scopes_supported: ['atproto'], 448 + response_types_supported: ['code'], 449 + grant_types_supported: ['authorization_code', 'refresh_token'], 450 + code_challenge_methods_supported: ['S256'], 451 + token_endpoint_auth_methods_supported: ['none'], 452 + dpop_signing_alg_values_supported: ['ES256'], 453 + require_pushed_authorization_requests: true, 454 + authorization_response_iss_parameter_supported: true 455 + }); 456 + } 457 + 458 + // OAuth Protected Resource Metadata 459 + if (path === '/.well-known/oauth-protected-resource' && method === 'GET') { 460 + const resource = `${url.protocol}//${url.host}`; 461 + return json({ 462 + resource, 463 + authorization_servers: [resource], 464 + bearer_methods_supported: ['header'], 465 + scopes_supported: ['atproto'] 466 + }); 467 + } 468 + 469 + // JWKS endpoint 470 + if (path === '/oauth/jwks' && method === 'GET') { 471 + const publicKeyJwk = await getPublicKeyJwk(this); 472 + return json({ 473 + keys: [{ ...publicKeyJwk, kid: 'pds-oauth-key', use: 'sig', alg: 'ES256' }] 474 + }); 475 + } 476 + ``` 477 + 478 + **Step 2: Add getPublicKeyJwk helper** 479 + 480 + ```javascript 481 + /** 482 + * Get the PDS signing key as a public JWK. 483 + * Exports only the public components (kty, crv, x, y) for use in JWKS. 484 + * @param {{ storage: DurableObjectStorage }} pds - The PDS instance 485 + * @returns {Promise<{ kty: string, crv: string, x: string, y: string }>} The public key in JWK format 486 + * @throws {Error} If the PDS is not initialized 487 + */ 488 + async function getPublicKeyJwk(pds) { 489 + const privateKeyHex = await pds.storage.get('privateKey'); 490 + if (!privateKeyHex) throw new Error('PDS not initialized'); 491 + 492 + const privateKeyBytes = hexToBytes(privateKeyHex); 493 + const privateKey = await crypto.subtle.importKey( 494 + 'pkcs8', privateKeyBytes, 495 + { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'] 496 + ); 497 + const jwk = await crypto.subtle.exportKey('jwk', privateKey); 498 + return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; 499 + } 500 + ``` 501 + 502 + **Step 3: Commit** 503 + 504 + ```bash 505 + git add src/pds.js 506 + git commit -m "feat(oauth): add discovery endpoints" 507 + ``` 508 + 509 + --- 510 + 511 + ## Task 6: Implement PAR Endpoint 512 + 513 + **Files:** 514 + - Modify: `src/pds.js` 515 + 516 + **Step 1: Add PAR handler** 517 + 518 + ```javascript 519 + if (path === '/oauth/par' && method === 'POST') { 520 + return handlePar(request, url, this, env); 521 + } 522 + 523 + /** 524 + * Handle Pushed Authorization Request (PAR) endpoint. 525 + * Validates DPoP proof, client metadata, PKCE parameters, and stores the authorization request. 526 + * @param {Request} request - The incoming request 527 + * @param {URL} url - Parsed request URL 528 + * @param {{ storage: DurableObjectStorage }} pds - The PDS instance 529 + * @param {{ PDS_PASSWORD: string }} env - Environment variables 530 + * @returns {Promise<Response>} JSON response with request_uri and expires_in 531 + */ 532 + async function handlePar(request, url, pds, env) { 533 + const issuer = `${url.protocol}//${url.host}`; 534 + 535 + const dpopHeader = request.headers.get('DPoP'); 536 + if (!dpopHeader) { 537 + return json({ error: 'invalid_dpop_proof', error_description: 'DPoP proof required' }, 400); 538 + } 539 + 540 + let dpop; 541 + try { 542 + dpop = await parseDpopProof(dpopHeader, 'POST', `${issuer}/oauth/par`); 543 + } catch (err) { 544 + return json({ error: 'invalid_dpop_proof', error_description: err.message }, 400); 545 + } 546 + 547 + const body = await request.text(); 548 + const params = new URLSearchParams(body); 549 + 550 + const clientId = params.get('client_id'); 551 + const redirectUri = params.get('redirect_uri'); 552 + const responseType = params.get('response_type'); 553 + const scope = params.get('scope'); 554 + const state = params.get('state'); 555 + const codeChallenge = params.get('code_challenge'); 556 + const codeChallengeMethod = params.get('code_challenge_method'); 557 + 558 + if (!clientId) return json({ error: 'invalid_request', error_description: 'client_id required' }, 400); 559 + if (!redirectUri) return json({ error: 'invalid_request', error_description: 'redirect_uri required' }, 400); 560 + if (responseType !== 'code') return json({ error: 'unsupported_response_type' }, 400); 561 + if (!codeChallenge || codeChallengeMethod !== 'S256') { 562 + return json({ error: 'invalid_request', error_description: 'PKCE with S256 required' }, 400); 563 + } 564 + 565 + let clientMetadata; 566 + try { 567 + clientMetadata = await getClientMetadata(clientId); 568 + } catch (err) { 569 + return json({ error: 'invalid_client', error_description: err.message }, 400); 570 + } 571 + 572 + const requestId = crypto.randomUUID(); 573 + const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; 574 + const expiresIn = 600; 575 + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); 576 + 577 + const sql = createSql(pds.storage); 578 + await sql` 579 + INSERT INTO authorization_requests ( 580 + id, client_id, client_metadata, parameters, 581 + code_challenge, code_challenge_method, dpop_jkt, 582 + expires_at, created_at 583 + ) VALUES ( 584 + ${requestId}, ${clientId}, ${JSON.stringify(clientMetadata)}, 585 + ${JSON.stringify({ redirect_uri: redirectUri, scope, state })}, 586 + ${codeChallenge}, ${codeChallengeMethod}, ${dpop.jkt}, 587 + ${expiresAt}, ${new Date().toISOString()} 588 + ) 589 + `; 590 + 591 + return json({ request_uri: requestUri, expires_in: expiresIn }); 592 + } 593 + ``` 594 + 595 + **Step 2: Commit** 596 + 597 + ```bash 598 + git add src/pds.js 599 + git commit -m "feat(oauth): implement PAR endpoint" 600 + ``` 601 + 602 + --- 603 + 604 + ## Task 7: Implement Authorization Endpoint 605 + 606 + **Files:** 607 + - Modify: `src/pds.js` 608 + 609 + **Step 1: Add GET handler (consent UI)** 610 + 611 + ```javascript 612 + if (path === '/oauth/authorize' && method === 'GET') { 613 + return handleAuthorizeGet(request, url, this, env); 614 + } 615 + 616 + /** 617 + * Handle GET /oauth/authorize - displays the consent UI. 618 + * Validates the request_uri from PAR and renders a login/consent form. 619 + * @param {Request} request - The incoming request 620 + * @param {URL} url - Parsed request URL 621 + * @param {{ storage: DurableObjectStorage }} pds - The PDS instance 622 + * @param {{ PDS_PASSWORD: string }} env - Environment variables 623 + * @returns {Promise<Response>} HTML consent page 624 + */ 625 + async function handleAuthorizeGet(request, url, pds, env) { 626 + const requestUri = url.searchParams.get('request_uri'); 627 + const clientId = url.searchParams.get('client_id'); 628 + 629 + if (!requestUri || !clientId) return new Response('Missing parameters', { status: 400 }); 630 + 631 + const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 632 + if (!match) return new Response('Invalid request_uri', { status: 400 }); 633 + 634 + const sql = createSql(pds.storage); 635 + const [authRequest] = await sql` 636 + SELECT * FROM authorization_requests WHERE id = ${match[1]} AND client_id = ${clientId} 637 + `; 638 + 639 + if (!authRequest) return new Response('Request not found', { status: 400 }); 640 + if (new Date(authRequest.expires_at) < new Date()) return new Response('Request expired', { status: 400 }); 641 + if (authRequest.code) return new Response('Request already used', { status: 400 }); 642 + 643 + const clientMetadata = JSON.parse(authRequest.client_metadata); 644 + const parameters = JSON.parse(authRequest.parameters); 645 + 646 + return new Response(renderConsentPage({ 647 + clientName: clientMetadata.client_name || clientId, 648 + clientId, scope: parameters.scope || 'atproto', requestUri 649 + }), { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); 650 + } 651 + 652 + /** 653 + * Render the OAuth consent page HTML. 654 + * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params 655 + * @returns {string} HTML page content 656 + */ 657 + function renderConsentPage({ clientName, clientId, scope, requestUri, error = '' }) { 658 + /** @param {string} s */ 659 + const escHtml = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); 660 + return `<!DOCTYPE html> 661 + <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> 662 + <title>Authorize</title> 663 + <style>body{font-family:system-ui;max-width:400px;margin:40px auto;padding:20px} 664 + .error{color:#c00;background:#fee;padding:10px;margin:10px 0} 665 + button{padding:10px 20px;margin:5px;cursor:pointer} 666 + .approve{background:#06c;color:#fff;border:none} 667 + input{width:100%;padding:8px;margin:5px 0;box-sizing:border-box}</style></head> 668 + <body><h2>Sign in to authorize</h2> 669 + <p><b>${escHtml(clientName)}</b> wants to access your account.</p> 670 + <p>Scope: ${escHtml(scope)}</p> 671 + ${error ? `<p class="error">${escHtml(error)}</p>` : ''} 672 + <form method="POST" action="/oauth/authorize"> 673 + <input type="hidden" name="request_uri" value="${escHtml(requestUri)}"> 674 + <input type="hidden" name="client_id" value="${escHtml(clientId)}"> 675 + <label>Password</label><input type="password" name="password" required autofocus> 676 + <div><button type="submit" name="action" value="deny">Deny</button> 677 + <button type="submit" name="action" value="approve" class="approve">Authorize</button></div> 678 + </form></body></html>`; 679 + } 680 + ``` 681 + 682 + **Step 2: Add POST handler (approval)** 683 + 684 + ```javascript 685 + if (path === '/oauth/authorize' && method === 'POST') { 686 + return handleAuthorizePost(request, url, this, env); 687 + } 688 + 689 + /** 690 + * Handle POST /oauth/authorize - processes user approval/denial. 691 + * Validates password, generates authorization code on approval, redirects to client. 692 + * @param {Request} request - The incoming request 693 + * @param {URL} url - Parsed request URL 694 + * @param {{ storage: DurableObjectStorage }} pds - The PDS instance 695 + * @param {{ PDS_PASSWORD: string }} env - Environment variables 696 + * @returns {Promise<Response>} Redirect to client redirect_uri with code or error 697 + */ 698 + async function handleAuthorizePost(request, url, pds, env) { 699 + const issuer = `${url.protocol}//${url.host}`; 700 + const body = await request.text(); 701 + const params = new URLSearchParams(body); 702 + 703 + const requestUri = params.get('request_uri'); 704 + const clientId = params.get('client_id'); 705 + const password = params.get('password'); 706 + const action = params.get('action'); 707 + 708 + const match = requestUri?.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 709 + if (!match) return new Response('Invalid request_uri', { status: 400 }); 710 + 711 + const sql = createSql(pds.storage); 712 + const [authRequest] = await sql` 713 + SELECT * FROM authorization_requests WHERE id = ${match[1]} AND client_id = ${clientId} 714 + `; 715 + if (!authRequest) return new Response('Request not found', { status: 400 }); 716 + 717 + const clientMetadata = JSON.parse(authRequest.client_metadata); 718 + const parameters = JSON.parse(authRequest.parameters); 719 + 720 + if (action === 'deny') { 721 + await sql`DELETE FROM authorization_requests WHERE id = ${match[1]}`; 722 + const errorUrl = new URL(parameters.redirect_uri); 723 + errorUrl.searchParams.set('error', 'access_denied'); 724 + if (parameters.state) errorUrl.searchParams.set('state', parameters.state); 725 + errorUrl.searchParams.set('iss', issuer); 726 + return Response.redirect(errorUrl.toString(), 302); 727 + } 728 + 729 + if (password !== env.PDS_PASSWORD) { 730 + return new Response(renderConsentPage({ 731 + clientName: clientMetadata.client_name || clientId, 732 + clientId, scope: parameters.scope || 'atproto', requestUri, error: 'Invalid password' 733 + }), { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); 734 + } 735 + 736 + const code = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); 737 + const did = await pds.storage.get('did'); 738 + 739 + await sql`UPDATE authorization_requests SET code = ${code}, did = ${did} WHERE id = ${match[1]}`; 740 + 741 + const successUrl = new URL(parameters.redirect_uri); 742 + successUrl.searchParams.set('code', code); 743 + if (parameters.state) successUrl.searchParams.set('state', parameters.state); 744 + successUrl.searchParams.set('iss', issuer); 745 + return Response.redirect(successUrl.toString(), 302); 746 + } 747 + ``` 748 + 749 + **Step 3: Commit** 750 + 751 + ```bash 752 + git add src/pds.js 753 + git commit -m "feat(oauth): implement authorization endpoint with consent UI" 754 + ``` 755 + 756 + --- 757 + 758 + ## Task 8: Implement Token Endpoint 759 + 760 + **Files:** 761 + - Modify: `src/pds.js` 762 + 763 + **Step 1: Add token handler** 764 + 765 + ```javascript 766 + if (path === '/oauth/token' && method === 'POST') { 767 + return handleToken(request, url, this, env); 768 + } 769 + 770 + /** 771 + * Handle token endpoint - exchanges authorization codes for tokens. 772 + * Supports authorization_code and refresh_token grant types. 773 + * @param {Request} request - The incoming request 774 + * @param {URL} url - Parsed request URL 775 + * @param {{ storage: DurableObjectStorage }} pds - The PDS instance 776 + * @param {{ PDS_PASSWORD: string }} env - Environment variables 777 + * @returns {Promise<Response>} JSON response with access_token, token_type, expires_in, refresh_token, scope 778 + */ 779 + async function handleToken(request, url, pds, env) { 780 + const issuer = `${url.protocol}//${url.host}`; 781 + 782 + const dpopHeader = request.headers.get('DPoP'); 783 + if (!dpopHeader) return json({ error: 'invalid_dpop_proof', error_description: 'DPoP required' }, 400); 784 + 785 + let dpop; 786 + try { 787 + dpop = await parseDpopProof(dpopHeader, 'POST', `${issuer}/oauth/token`); 788 + } catch (err) { 789 + return json({ error: 'invalid_dpop_proof', error_description: err.message }, 400); 790 + } 791 + 792 + const body = await request.text(); 793 + const params = new URLSearchParams(body); 794 + const grantType = params.get('grant_type'); 795 + 796 + if (grantType === 'authorization_code') { 797 + return handleAuthCodeGrant(params, dpop, issuer, pds); 798 + } else if (grantType === 'refresh_token') { 799 + return handleRefreshGrant(params, dpop, issuer, pds); 800 + } 801 + return json({ error: 'unsupported_grant_type' }, 400); 802 + } 803 + 804 + /** 805 + * Handle authorization_code grant type. 806 + * Validates the code, PKCE verifier, and DPoP binding, then issues tokens. 807 + * @param {URLSearchParams} params - Token request parameters 808 + * @param {DpopProofResult} dpop - Parsed DPoP proof 809 + * @param {string} issuer - The PDS issuer URL 810 + * @param {{ storage: DurableObjectStorage }} pds - The PDS instance 811 + * @returns {Promise<Response>} JSON token response 812 + */ 813 + async function handleAuthCodeGrant(params, dpop, issuer, pds) { 814 + const code = params.get('code'); 815 + const redirectUri = params.get('redirect_uri'); 816 + const clientId = params.get('client_id'); 817 + const codeVerifier = params.get('code_verifier'); 818 + 819 + if (!code || !redirectUri || !clientId || !codeVerifier) { 820 + return json({ error: 'invalid_request' }, 400); 821 + } 822 + 823 + const sql = createSql(pds.storage); 824 + const [authRequest] = await sql`SELECT * FROM authorization_requests WHERE code = ${code}`; 825 + if (!authRequest) return json({ error: 'invalid_grant', error_description: 'Invalid code' }, 400); 826 + if (authRequest.client_id !== clientId) return json({ error: 'invalid_grant' }, 400); 827 + if (authRequest.dpop_jkt !== dpop.jkt) return json({ error: 'invalid_dpop_proof' }, 400); 828 + 829 + const parameters = JSON.parse(authRequest.parameters); 830 + if (parameters.redirect_uri !== redirectUri) return json({ error: 'invalid_grant' }, 400); 831 + 832 + // Verify PKCE 833 + const challengeHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)); 834 + const computedChallenge = base64UrlEncode(new Uint8Array(challengeHash)); 835 + if (computedChallenge !== authRequest.code_challenge) { 836 + return json({ error: 'invalid_grant', error_description: 'Invalid code_verifier' }, 400); 837 + } 838 + 839 + await sql`DELETE FROM authorization_requests WHERE id = ${authRequest.id}`; 840 + 841 + const tokenId = crypto.randomUUID(); 842 + const refreshToken = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); 843 + const scope = parameters.scope || 'atproto'; 844 + const now = new Date(); 845 + const expiresIn = 3600; 846 + 847 + const accessToken = await createOAuthAccessToken({ 848 + issuer, subject: authRequest.did, clientId, scope, tokenId, dpopJkt: dpop.jkt, expiresIn 849 + }, pds); 850 + 851 + await sql` 852 + INSERT INTO tokens (token_id, did, client_id, scope, dpop_jkt, expires_at, refresh_token, created_at, updated_at) 853 + VALUES (${tokenId}, ${authRequest.did}, ${clientId}, ${scope}, ${dpop.jkt}, 854 + ${new Date(now.getTime() + expiresIn * 1000).toISOString()}, 855 + ${refreshToken}, ${now.toISOString()}, ${now.toISOString()}) 856 + `; 857 + 858 + return json({ access_token: accessToken, token_type: 'DPoP', expires_in: expiresIn, refresh_token: refreshToken, scope }); 859 + } 860 + 861 + /** 862 + * @typedef {Object} AccessTokenParams 863 + * @property {string} issuer - The PDS issuer URL 864 + * @property {string} subject - The DID of the authenticated user 865 + * @property {string} clientId - The OAuth client_id 866 + * @property {string} scope - The granted scope 867 + * @property {string} tokenId - Unique token identifier (jti) 868 + * @property {string} dpopJkt - The DPoP key thumbprint for token binding 869 + * @property {number} expiresIn - Token lifetime in seconds 870 + */ 871 + 872 + /** 873 + * Create a DPoP-bound access token (at+jwt). 874 + * @param {AccessTokenParams} params - Token parameters 875 + * @param {{ storage: DurableObjectStorage }} pds - The PDS instance 876 + * @returns {Promise<string>} The signed JWT access token 877 + */ 878 + async function createOAuthAccessToken({ issuer, subject, clientId, scope, tokenId, dpopJkt, expiresIn }, pds) { 879 + const now = Math.floor(Date.now() / 1000); 880 + const header = { typ: 'at+jwt', alg: 'ES256', kid: 'pds-oauth-key' }; 881 + const payload = { 882 + iss: issuer, sub: subject, aud: issuer, client_id: clientId, 883 + scope, jti: tokenId, iat: now, exp: now + expiresIn, cnf: { jkt: dpopJkt } 884 + }; 885 + 886 + const privateKeyHex = await pds.storage.get('privateKey'); 887 + const privateKey = await importPrivateKey(hexToBytes(privateKeyHex)); 888 + 889 + const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))); 890 + const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))); 891 + const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 892 + const sig = await sign(privateKey, sigInput); 893 + 894 + return `${headerB64}.${payloadB64}.${base64UrlEncode(sig)}`; 895 + } 896 + ``` 897 + 898 + **Step 2: Commit** 899 + 900 + ```bash 901 + git add src/pds.js 902 + git commit -m "feat(oauth): implement token endpoint" 903 + ``` 904 + 905 + --- 906 + 907 + ## Task 9: Implement Refresh Token Grant 908 + 909 + **Files:** 910 + - Modify: `src/pds.js` 911 + 912 + **Step 1: Add refresh handler** 913 + 914 + ```javascript 915 + /** 916 + * Handle refresh_token grant type. 917 + * Validates the refresh token, DPoP binding, and 24hr lifetime, then rotates tokens. 918 + * @param {URLSearchParams} params - Token request parameters 919 + * @param {DpopProofResult} dpop - Parsed DPoP proof 920 + * @param {string} issuer - The PDS issuer URL 921 + * @param {{ storage: DurableObjectStorage }} pds - The PDS instance 922 + * @returns {Promise<Response>} JSON token response with new access and refresh tokens 923 + */ 924 + async function handleRefreshGrant(params, dpop, issuer, pds) { 925 + const refreshToken = params.get('refresh_token'); 926 + const clientId = params.get('client_id'); 927 + 928 + if (!refreshToken || !clientId) return json({ error: 'invalid_request' }, 400); 929 + 930 + const sql = createSql(pds.storage); 931 + const [token] = await sql`SELECT * FROM tokens WHERE refresh_token = ${refreshToken}`; 932 + 933 + if (!token) return json({ error: 'invalid_grant', error_description: 'Invalid refresh token' }, 400); 934 + if (token.client_id !== clientId) return json({ error: 'invalid_grant' }, 400); 935 + if (token.dpop_jkt !== dpop.jkt) return json({ error: 'invalid_dpop_proof' }, 400); 936 + 937 + // Check 24hr lifetime 938 + const createdAt = new Date(token.created_at); 939 + if (Date.now() - createdAt.getTime() > 24 * 60 * 60 * 1000) { 940 + await sql`DELETE FROM tokens WHERE id = ${token.id}`; 941 + return json({ error: 'invalid_grant', error_description: 'Refresh token expired' }, 400); 942 + } 943 + 944 + const newTokenId = crypto.randomUUID(); 945 + const newRefreshToken = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); 946 + const now = new Date(); 947 + const expiresIn = 3600; 948 + 949 + const accessToken = await createOAuthAccessToken({ 950 + issuer, subject: token.did, clientId, scope: token.scope, 951 + tokenId: newTokenId, dpopJkt: dpop.jkt, expiresIn 952 + }, pds); 953 + 954 + await sql` 955 + UPDATE tokens SET token_id = ${newTokenId}, refresh_token = ${newRefreshToken}, 956 + expires_at = ${new Date(now.getTime() + expiresIn * 1000).toISOString()}, 957 + updated_at = ${now.toISOString()} WHERE id = ${token.id} 958 + `; 959 + 960 + return json({ access_token: accessToken, token_type: 'DPoP', expires_in: expiresIn, refresh_token: newRefreshToken, scope: token.scope }); 961 + } 962 + ``` 963 + 964 + **Step 2: Commit** 965 + 966 + ```bash 967 + git add src/pds.js 968 + git commit -m "feat(oauth): implement refresh token grant" 969 + ``` 970 + 971 + --- 972 + 973 + ## Task 10: Update requireAuth for DPoP Tokens 974 + 975 + **Files:** 976 + - Modify: `src/pds.js` 977 + 978 + **Step 1: Update requireAuth** 979 + 980 + ```javascript 981 + /** 982 + * @typedef {Object} AuthResult 983 + * @property {string} did - The authenticated user's DID 984 + * @property {string} [scope] - The granted scope (for OAuth tokens) 985 + */ 986 + 987 + /** 988 + * Require authentication for a request. 989 + * Supports both legacy Bearer tokens (JWT with symmetric key) and OAuth DPoP tokens. 990 + * @param {Request} request - The incoming request 991 + * @param {{ JWT_SECRET: string, PDS_PASSWORD: string }} env - Environment variables 992 + * @param {{ storage: DurableObjectStorage }} pds - The PDS instance 993 + * @returns {Promise<AuthResult>} The authenticated user's DID and scope 994 + * @throws {AuthRequiredError} If authentication fails 995 + */ 996 + async function requireAuth(request, env, pds) { 997 + const authHeader = request.headers.get('Authorization'); 998 + if (!authHeader) throw new AuthRequiredError('Authorization required'); 999 + 1000 + if (authHeader.startsWith('Bearer ')) { 1001 + return verifyAccessJwt(authHeader.slice(7), env.JWT_SECRET); 1002 + } 1003 + 1004 + if (authHeader.startsWith('DPoP ')) { 1005 + return verifyOAuthAccessToken(request, authHeader.slice(5), pds); 1006 + } 1007 + 1008 + throw new AuthRequiredError('Invalid authorization type'); 1009 + } 1010 + 1011 + /** 1012 + * Verify an OAuth DPoP-bound access token. 1013 + * Validates the JWT signature, expiration, DPoP binding, and proof. 1014 + * @param {Request} request - The incoming request (for DPoP validation) 1015 + * @param {string} token - The access token JWT 1016 + * @param {{ storage: DurableObjectStorage }} pds - The PDS instance 1017 + * @returns {Promise<AuthResult>} The authenticated user's DID and scope 1018 + * @throws {AuthRequiredError} If verification fails 1019 + */ 1020 + async function verifyOAuthAccessToken(request, token, pds) { 1021 + const parts = token.split('.'); 1022 + if (parts.length !== 3) throw new AuthRequiredError('Invalid token format'); 1023 + 1024 + const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))); 1025 + if (header.typ !== 'at+jwt') throw new AuthRequiredError('Invalid token type'); 1026 + 1027 + // Verify signature with PDS public key 1028 + const publicKeyJwk = await getPublicKeyJwk(pds); 1029 + const publicKey = await crypto.subtle.importKey( 1030 + 'jwk', publicKeyJwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'] 1031 + ); 1032 + 1033 + const signatureInput = new TextEncoder().encode(parts[0] + '.' + parts[1]); 1034 + const signature = base64UrlDecode(parts[2]); 1035 + 1036 + const valid = await crypto.subtle.verify( 1037 + { name: 'ECDSA', hash: 'SHA-256' }, publicKey, 1038 + compactSignatureToDer(signature), signatureInput 1039 + ); 1040 + if (!valid) throw new AuthRequiredError('Invalid token signature'); 1041 + 1042 + const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))); 1043 + 1044 + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { 1045 + throw new AuthRequiredError('Token expired'); 1046 + } 1047 + 1048 + if (!payload.cnf?.jkt) throw new AuthRequiredError('Token missing DPoP binding'); 1049 + 1050 + const dpopHeader = request.headers.get('DPoP'); 1051 + if (!dpopHeader) throw new AuthRequiredError('DPoP proof required'); 1052 + 1053 + const url = new URL(request.url); 1054 + await parseDpopProof(dpopHeader, request.method, `${url.protocol}//${url.host}${url.pathname}`, payload.cnf.jkt, token); 1055 + 1056 + return { did: payload.sub, scope: payload.scope }; 1057 + } 1058 + ``` 1059 + 1060 + **Step 2: Commit** 1061 + 1062 + ```bash 1063 + git add src/pds.js 1064 + git commit -m "feat(oauth): update requireAuth to handle DPoP tokens" 1065 + ``` 1066 + 1067 + --- 1068 + 1069 + ## Task 11: Add Revocation Endpoint 1070 + 1071 + **Files:** 1072 + - Modify: `src/pds.js` 1073 + 1074 + **Step 1: Add revoke handler** 1075 + 1076 + ```javascript 1077 + if (path === '/oauth/revoke' && method === 'POST') { 1078 + return handleRevoke(request, url, this, env); 1079 + } 1080 + 1081 + /** 1082 + * Handle token revocation endpoint (RFC 7009). 1083 + * Revokes access tokens and refresh tokens by client_id. 1084 + * @param {Request} request - The incoming request 1085 + * @param {URL} url - Parsed request URL 1086 + * @param {{ storage: DurableObjectStorage }} pds - The PDS instance 1087 + * @param {{ PDS_PASSWORD: string }} env - Environment variables 1088 + * @returns {Promise<Response>} Empty 200 response on success 1089 + */ 1090 + async function handleRevoke(request, url, pds, env) { 1091 + const body = await request.text(); 1092 + const params = new URLSearchParams(body); 1093 + const token = params.get('token'); 1094 + const clientId = params.get('client_id'); 1095 + 1096 + if (!token || !clientId) return json({ error: 'invalid_request' }, 400); 1097 + 1098 + const sql = createSql(pds.storage); 1099 + await sql` 1100 + DELETE FROM tokens WHERE client_id = ${clientId} 1101 + AND (refresh_token = ${token} OR token_id = ${token}) 1102 + `; 1103 + 1104 + return new Response(null, { status: 200 }); 1105 + } 1106 + ``` 1107 + 1108 + **Step 2: Commit** 1109 + 1110 + ```bash 1111 + git add src/pds.js 1112 + git commit -m "feat(oauth): add token revocation endpoint" 1113 + ``` 1114 + 1115 + --- 1116 + 1117 + ## Task 12: Add OAuth E2E Tests 1118 + 1119 + **Files:** 1120 + - Modify: `test/e2e.sh` 1121 + 1122 + **Step 1: Add OAuth tests to e2e.sh** 1123 + 1124 + Add after the existing tests: 1125 + 1126 + ```bash 1127 + # OAuth tests 1128 + echo 1129 + echo "Testing OAuth endpoints..." 1130 + 1131 + # Test OAuth Authorization Server Metadata 1132 + echo "Testing OAuth AS metadata..." 1133 + AS_METADATA=$(curl -sf "$BASE/.well-known/oauth-authorization-server") 1134 + echo "$AS_METADATA" | jq -e '.issuer == "'"$BASE"'"' >/dev/null && 1135 + pass "AS metadata: issuer matches base URL" || fail "AS metadata: issuer mismatch" 1136 + echo "$AS_METADATA" | jq -e '.authorization_endpoint == "'"$BASE"'/oauth/authorize"' >/dev/null && 1137 + pass "AS metadata: authorization_endpoint" || fail "AS metadata: authorization_endpoint" 1138 + echo "$AS_METADATA" | jq -e '.token_endpoint == "'"$BASE"'/oauth/token"' >/dev/null && 1139 + pass "AS metadata: token_endpoint" || fail "AS metadata: token_endpoint" 1140 + echo "$AS_METADATA" | jq -e '.pushed_authorization_request_endpoint == "'"$BASE"'/oauth/par"' >/dev/null && 1141 + pass "AS metadata: PAR endpoint" || fail "AS metadata: PAR endpoint" 1142 + echo "$AS_METADATA" | jq -e '.revocation_endpoint == "'"$BASE"'/oauth/revoke"' >/dev/null && 1143 + pass "AS metadata: revocation_endpoint" || fail "AS metadata: revocation_endpoint" 1144 + echo "$AS_METADATA" | jq -e '.jwks_uri == "'"$BASE"'/oauth/jwks"' >/dev/null && 1145 + pass "AS metadata: jwks_uri" || fail "AS metadata: jwks_uri" 1146 + echo "$AS_METADATA" | jq -e '.scopes_supported | contains(["atproto"])' >/dev/null && 1147 + pass "AS metadata: scopes_supported includes atproto" || fail "AS metadata: scopes_supported" 1148 + echo "$AS_METADATA" | jq -e '.response_types_supported | contains(["code"])' >/dev/null && 1149 + pass "AS metadata: response_types_supported" || fail "AS metadata: response_types_supported" 1150 + echo "$AS_METADATA" | jq -e '.grant_types_supported | contains(["authorization_code", "refresh_token"])' >/dev/null && 1151 + pass "AS metadata: grant_types_supported" || fail "AS metadata: grant_types_supported" 1152 + echo "$AS_METADATA" | jq -e '.code_challenge_methods_supported | contains(["S256"])' >/dev/null && 1153 + pass "AS metadata: PKCE S256 supported" || fail "AS metadata: PKCE S256" 1154 + echo "$AS_METADATA" | jq -e '.dpop_signing_alg_values_supported | contains(["ES256"])' >/dev/null && 1155 + pass "AS metadata: DPoP ES256 supported" || fail "AS metadata: DPoP ES256" 1156 + echo "$AS_METADATA" | jq -e '.require_pushed_authorization_requests == true' >/dev/null && 1157 + pass "AS metadata: PAR required" || fail "AS metadata: PAR required" 1158 + echo "$AS_METADATA" | jq -e '.authorization_response_iss_parameter_supported == true' >/dev/null && 1159 + pass "AS metadata: iss parameter supported" || fail "AS metadata: iss parameter" 1160 + 1161 + # Test OAuth Protected Resource Metadata 1162 + echo "Testing OAuth PR metadata..." 1163 + PR_METADATA=$(curl -sf "$BASE/.well-known/oauth-protected-resource") 1164 + echo "$PR_METADATA" | jq -e '.resource == "'"$BASE"'"' >/dev/null && 1165 + pass "PR metadata: resource matches base URL" || fail "PR metadata: resource mismatch" 1166 + echo "$PR_METADATA" | jq -e '.authorization_servers | contains(["'"$BASE"'"])' >/dev/null && 1167 + pass "PR metadata: authorization_servers" || fail "PR metadata: authorization_servers" 1168 + echo "$PR_METADATA" | jq -e '.scopes_supported | contains(["atproto"])' >/dev/null && 1169 + pass "PR metadata: scopes_supported" || fail "PR metadata: scopes_supported" 1170 + 1171 + # Test JWKS endpoint 1172 + echo "Testing JWKS endpoint..." 1173 + JWKS=$(curl -sf "$BASE/oauth/jwks") 1174 + echo "$JWKS" | jq -e '.keys | length > 0' >/dev/null && 1175 + pass "JWKS: has at least one key" || fail "JWKS: no keys" 1176 + echo "$JWKS" | jq -e '.keys[0].kty == "EC"' >/dev/null && 1177 + pass "JWKS: key is EC type" || fail "JWKS: key type" 1178 + echo "$JWKS" | jq -e '.keys[0].crv == "P-256"' >/dev/null && 1179 + pass "JWKS: key uses P-256 curve" || fail "JWKS: curve" 1180 + echo "$JWKS" | jq -e '.keys[0].alg == "ES256"' >/dev/null && 1181 + pass "JWKS: key algorithm is ES256" || fail "JWKS: algorithm" 1182 + echo "$JWKS" | jq -e '.keys[0].use == "sig"' >/dev/null && 1183 + pass "JWKS: key use is sig" || fail "JWKS: key use" 1184 + echo "$JWKS" | jq -e '.keys[0].kid == "pds-oauth-key"' >/dev/null && 1185 + pass "JWKS: kid is pds-oauth-key" || fail "JWKS: kid" 1186 + echo "$JWKS" | jq -e '.keys[0] | has("x") and has("y")' >/dev/null && 1187 + pass "JWKS: has x and y coordinates" || fail "JWKS: coordinates" 1188 + echo "$JWKS" | jq -e '.keys[0] | has("d") | not' >/dev/null && 1189 + pass "JWKS: does not expose private key (d)" || fail "JWKS: private key exposed!" 1190 + 1191 + # Test PAR endpoint error cases 1192 + echo "Testing PAR error handling..." 1193 + PAR_NO_DPOP=$(curl -s -w "\n%{http_code}" -X POST "$BASE/oauth/par" \ 1194 + -H "Content-Type: application/x-www-form-urlencoded" \ 1195 + -d "client_id=http://localhost:3000&redirect_uri=http://localhost:3000/callback&response_type=code&scope=atproto&code_challenge=test&code_challenge_method=S256") 1196 + PAR_BODY=$(echo "$PAR_NO_DPOP" | head -n -1) 1197 + PAR_STATUS=$(echo "$PAR_NO_DPOP" | tail -n 1) 1198 + [ "$PAR_STATUS" = "400" ] && pass "PAR: rejects missing DPoP (400)" || fail "PAR: should reject missing DPoP" 1199 + echo "$PAR_BODY" | jq -e '.error == "invalid_dpop_proof"' >/dev/null && 1200 + pass "PAR: error code is invalid_dpop_proof" || fail "PAR: wrong error code" 1201 + 1202 + # Test token endpoint error cases 1203 + echo "Testing token endpoint error handling..." 1204 + TOKEN_NO_DPOP=$(curl -s -w "\n%{http_code}" -X POST "$BASE/oauth/token" \ 1205 + -H "Content-Type: application/x-www-form-urlencoded" \ 1206 + -d "grant_type=authorization_code&code=fake&client_id=http://localhost:3000") 1207 + TOKEN_BODY=$(echo "$TOKEN_NO_DPOP" | head -n -1) 1208 + TOKEN_STATUS=$(echo "$TOKEN_NO_DPOP" | tail -n 1) 1209 + [ "$TOKEN_STATUS" = "400" ] && pass "Token: rejects missing DPoP (400)" || fail "Token: should reject missing DPoP" 1210 + echo "$TOKEN_BODY" | jq -e '.error == "invalid_dpop_proof"' >/dev/null && 1211 + pass "Token: error code is invalid_dpop_proof" || fail "Token: wrong error code" 1212 + 1213 + # Test revoke endpoint (should accept without valid token - RFC 7009 says always 200) 1214 + echo "Testing revoke endpoint..." 1215 + REVOKE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/oauth/revoke" \ 1216 + -H "Content-Type: application/x-www-form-urlencoded" \ 1217 + -d "token=nonexistent&client_id=http://localhost:3000") 1218 + [ "$REVOKE_STATUS" = "200" ] && pass "Revoke: returns 200 even for invalid token" || fail "Revoke: should always return 200" 1219 + 1220 + echo 1221 + echo "All OAuth endpoint tests passed!" 1222 + ``` 1223 + 1224 + **Step 2: Commit** 1225 + 1226 + ```bash 1227 + git add test/e2e.sh 1228 + git commit -m "test(oauth): add comprehensive OAuth e2e tests" 1229 + ``` 1230 + 1231 + --- 1232 + 1233 + ## Task 13: Run Typecheck and Fix Any Errors 1234 + 1235 + **Files:** 1236 + - Modify: `src/pds.js` (if needed) 1237 + 1238 + **Step 1: Run TypeScript type checking** 1239 + 1240 + ```bash 1241 + npm run typecheck 1242 + ``` 1243 + 1244 + Expect: No type errors. If there are errors, fix them before continuing. 1245 + 1246 + **Step 2: Run unit tests** 1247 + 1248 + ```bash 1249 + npm test 1250 + ``` 1251 + 1252 + Expect: All tests pass. 1253 + 1254 + **Step 3: Run e2e tests** 1255 + 1256 + Start wrangler dev in one terminal, then run: 1257 + 1258 + ```bash 1259 + ./test/e2e.sh 1260 + ``` 1261 + 1262 + Expect: All tests pass. 1263 + 1264 + **Step 4: Final commit (if any fixes were needed)** 1265 + 1266 + ```bash 1267 + git add src/pds.js 1268 + git commit -m "fix(oauth): address typecheck errors" 1269 + ``` 1270 + 1271 + --- 1272 + 1273 + ## Summary 1274 + 1275 + This plan implements AT Protocol OAuth with: 1276 + - PAR (Pushed Authorization Requests) 1277 + - DPoP (Demonstration of Proof-of-Possession) 1278 + - PKCE (Proof Key for Code Exchange) 1279 + - Authorization code flow with consent UI 1280 + - Token refresh and revocation 1281 + - Backward compatibility with existing Bearer tokens 1282 + 1283 + All implemented with zero external dependencies using Web Crypto APIs.
+3 -3
package.json
··· 7 7 "dev": "wrangler dev --persist-to .wrangler/state", 8 8 "dev:remote": "wrangler dev --remote", 9 9 "deploy": "wrangler deploy", 10 - "test": "node --test test/*.test.js", 11 - "test:e2e": "./test/e2e.sh", 10 + "test": "node --test test/pds.test.js", 11 + "test:e2e": "node --test test/e2e.test.js", 12 12 "setup": "node scripts/setup.js", 13 - "format": "biome format --write . && shfmt -w -i 2 test/*.sh", 13 + "format": "biome format --write .", 14 14 "lint": "biome lint .", 15 15 "check": "biome check .", 16 16 "typecheck": "tsc --noEmit"
+1508 -97
src/pds.js
··· 17 17 // ║ • Merkle Search Tree (MST) for repository structure ║ 18 18 // ║ • P-256 signing with low-S normalization ║ 19 19 // ║ • JWT authentication (access, refresh, service tokens) ║ 20 + // ║ • OAuth 2.0 with DPoP, PKCE, and token management ║ 20 21 // ║ • CAR file building for repo sync ║ 21 22 // ║ • R2 blob storage with MIME detection ║ 22 23 // ║ • SQLite persistence via Durable Objects ║ ··· 124 125 * @property {string} [jti] - Unique token identifier 125 126 */ 126 127 128 + /** 129 + * OAuth client metadata from client_id URL 130 + * @typedef {Object} ClientMetadata 131 + * @property {string} client_id - The client identifier (must match the URL used to fetch metadata) 132 + * @property {string} [client_name] - Human-readable client name 133 + * @property {string[]} redirect_uris - Allowed redirect URIs 134 + * @property {string[]} grant_types - Supported grant types 135 + * @property {string[]} response_types - Supported response types 136 + * @property {string} [token_endpoint_auth_method] - Token endpoint auth method 137 + * @property {boolean} [dpop_bound_access_tokens] - Whether client requires DPoP-bound tokens 138 + * @property {string} [scope] - Default scope 139 + */ 140 + 141 + /** 142 + * Parsed and validated DPoP proof 143 + * @typedef {Object} DpopProofResult 144 + * @property {string} jkt - The JWK thumbprint of the DPoP key 145 + * @property {string} jti - The unique identifier from the DPoP proof 146 + * @property {number} iat - The issued-at timestamp from the DPoP proof 147 + * @property {{ kty: string, crv: string, x: string, y: string }} jwk - The public key from the proof 148 + */ 149 + 150 + /** 151 + * Parameters for creating a DPoP-bound access token 152 + * @typedef {Object} AccessTokenParams 153 + * @property {string} issuer - The PDS issuer URL 154 + * @property {string} subject - The DID of the authenticated user 155 + * @property {string} clientId - The OAuth client_id 156 + * @property {string} scope - The granted scope 157 + * @property {string} tokenId - Unique token identifier (jti) 158 + * @property {string} dpopJkt - The DPoP key thumbprint for token binding 159 + * @property {number} expiresIn - Token lifetime in seconds 160 + */ 161 + 127 162 // ╔══════════════════════════════════════════════════════════════════════════════╗ 128 163 // ║ UTILITIES ║ 129 164 // ║ Error responses, byte conversion, base encoding ║ ··· 140 175 } 141 176 142 177 /** 178 + * Get the default PDS Durable Object stub. 179 + * @param {Env} env - Environment bindings 180 + * @returns {{ fetch: (req: Request) => Promise<Response> }} Default PDS stub 181 + */ 182 + function getDefaultPds(env) { 183 + const id = env.PDS.idFromName('default'); 184 + return env.PDS.get(id); 185 + } 186 + 187 + /** 188 + * Parse request body supporting both JSON and form-encoded formats. 189 + * @param {Request} request - The incoming request 190 + * @returns {Promise<Record<string, string>>} Parsed body data 191 + * @throws {Error} If JSON parsing fails 192 + */ 193 + async function parseRequestBody(request) { 194 + const contentType = request.headers.get('content-type') || ''; 195 + const body = await request.text(); 196 + if (contentType.includes('application/json')) { 197 + return JSON.parse(body); 198 + } 199 + const params = new URLSearchParams(body); 200 + return Object.fromEntries(params.entries()); 201 + } 202 + 203 + /** 204 + * Validate that required parameters are present in data object. 205 + * @param {Record<string, unknown>} data - Data object to validate 206 + * @param {string[]} required - List of required parameter names 207 + * @returns {{ valid: true } | { valid: false, missing: string[] }} Validation result 208 + */ 209 + function validateRequiredParams(data, required) { 210 + const missing = required.filter((key) => !data[key]); 211 + if (missing.length > 0) { 212 + return { valid: false, missing }; 213 + } 214 + return { valid: true }; 215 + } 216 + 217 + /** 143 218 * Convert bytes to hexadecimal string 144 219 * @param {Uint8Array} bytes - Bytes to convert 145 220 * @returns {string} Hex string ··· 270 345 bytes[i] = binary.charCodeAt(i); 271 346 } 272 347 return bytes; 348 + } 349 + 350 + /** 351 + * Timing-safe string comparison using constant-time comparison. 352 + * Compares hashes of strings to prevent timing attacks. 353 + * @param {string} a - First string to compare 354 + * @param {string} b - Second string to compare 355 + * @returns {Promise<boolean>} True if strings are equal 356 + */ 357 + export async function timingSafeEqual(a, b) { 358 + const encoder = new TextEncoder(); 359 + const aBytes = encoder.encode(a); 360 + const bBytes = encoder.encode(b); 361 + 362 + // Hash both to ensure constant-time comparison regardless of length 363 + const [aHash, bHash] = await Promise.all([ 364 + crypto.subtle.digest('SHA-256', aBytes), 365 + crypto.subtle.digest('SHA-256', bBytes), 366 + ]); 367 + 368 + const aArr = new Uint8Array(aHash); 369 + const bArr = new Uint8Array(bHash); 370 + 371 + // Constant-time comparison 372 + let result = 0; 373 + for (let i = 0; i < aArr.length; i++) { 374 + result |= aArr[i] ^ bArr[i]; 375 + } 376 + return result === 0; 377 + } 378 + 379 + /** 380 + * Compute JWK thumbprint (SHA-256) per RFC 7638. 381 + * Creates a canonical JSON representation of EC key required members 382 + * and returns the base64url-encoded SHA-256 hash. 383 + * @param {{ kty: string, crv: string, x: string, y: string }} jwk - The EC public key in JWK format 384 + * @returns {Promise<string>} The base64url-encoded thumbprint 385 + */ 386 + export async function computeJwkThumbprint(jwk) { 387 + // RFC 7638: members must be in lexicographic order 388 + const thumbprintInput = JSON.stringify({ 389 + crv: jwk.crv, 390 + kty: jwk.kty, 391 + x: jwk.x, 392 + y: jwk.y, 393 + }); 394 + const hash = await crypto.subtle.digest( 395 + 'SHA-256', 396 + new TextEncoder().encode(thumbprintInput), 397 + ); 398 + return base64UrlEncode(new Uint8Array(hash)); 399 + } 400 + 401 + /** 402 + * Check if a client_id represents a loopback client (localhost development). 403 + * Loopback clients are allowed without pre-registration per AT Protocol OAuth spec. 404 + * @param {string} clientId - The client_id to check 405 + * @returns {boolean} True if the client_id is a loopback address 406 + */ 407 + export function isLoopbackClient(clientId) { 408 + try { 409 + const url = new URL(clientId); 410 + const host = url.hostname.toLowerCase(); 411 + return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'; 412 + } catch { 413 + return false; 414 + } 415 + } 416 + 417 + /** 418 + * Generate permissive client metadata for a loopback client. 419 + * @param {string} clientId - The loopback client_id 420 + * @returns {ClientMetadata} Generated client metadata 421 + */ 422 + export function getLoopbackClientMetadata(clientId) { 423 + return { 424 + client_id: clientId, 425 + client_name: 'Loopback Client', 426 + redirect_uris: [clientId], 427 + grant_types: ['authorization_code', 'refresh_token'], 428 + response_types: ['code'], 429 + token_endpoint_auth_method: 'none', 430 + dpop_bound_access_tokens: true, 431 + scope: 'atproto', 432 + }; 433 + } 434 + 435 + /** 436 + * Validate client metadata against AT Protocol OAuth requirements. 437 + * @param {ClientMetadata} metadata - The client metadata to validate 438 + * @param {string} expectedClientId - The expected client_id (the URL used to fetch metadata) 439 + * @throws {Error} If validation fails 440 + */ 441 + export function validateClientMetadata(metadata, expectedClientId) { 442 + if (!metadata.client_id) throw new Error('client_id is required'); 443 + if (metadata.client_id !== expectedClientId) 444 + throw new Error('client_id mismatch'); 445 + if ( 446 + !Array.isArray(metadata.redirect_uris) || 447 + metadata.redirect_uris.length === 0 448 + ) { 449 + throw new Error('redirect_uris is required'); 450 + } 451 + if (!metadata.grant_types?.includes('authorization_code')) { 452 + throw new Error('grant_types must include authorization_code'); 453 + } 454 + } 455 + 456 + /** @type {Map<string, { metadata: ClientMetadata, expiresAt: number }>} */ 457 + const clientMetadataCache = new Map(); 458 + 459 + /** 460 + * Fetch and validate client metadata from a client_id URL. 461 + * Caches results for 10 minutes. Loopback clients return synthetic metadata. 462 + * @param {string} clientId - The client_id (URL to fetch metadata from) 463 + * @returns {Promise<ClientMetadata>} The validated client metadata 464 + * @throws {Error} If fetching or validation fails 465 + */ 466 + async function getClientMetadata(clientId) { 467 + const cached = clientMetadataCache.get(clientId); 468 + if (cached && Date.now() < cached.expiresAt) return cached.metadata; 469 + 470 + if (isLoopbackClient(clientId)) { 471 + const metadata = getLoopbackClientMetadata(clientId); 472 + clientMetadataCache.set(clientId, { 473 + metadata, 474 + expiresAt: Date.now() + 600000, 475 + }); 476 + return metadata; 477 + } 478 + 479 + const response = await fetch(clientId, { 480 + headers: { Accept: 'application/json' }, 481 + }); 482 + if (!response.ok) 483 + throw new Error(`Failed to fetch client metadata: ${response.status}`); 484 + 485 + const metadata = await response.json(); 486 + validateClientMetadata(metadata, clientId); 487 + clientMetadataCache.set(clientId, { 488 + metadata, 489 + expiresAt: Date.now() + 600000, 490 + }); 491 + return metadata; 492 + } 493 + 494 + /** 495 + * Parse and validate a DPoP proof JWT. 496 + * Verifies the signature, checks claims (htm, htu, iat, jti), and optionally 497 + * validates key binding (expectedJkt) and access token hash (ath). 498 + * @param {string} proof - The DPoP proof JWT 499 + * @param {string} method - The expected HTTP method (htm claim) 500 + * @param {string} url - The expected request URL (htu claim) 501 + * @param {string|null} [expectedJkt=null] - If provided, verify the key matches this thumbprint 502 + * @param {string|null} [accessToken=null] - If provided, verify the ath claim matches this token's hash 503 + * @returns {Promise<DpopProofResult>} The parsed proof with jkt, jti, and jwk 504 + * @throws {Error} If validation fails 505 + */ 506 + async function parseDpopProof( 507 + proof, 508 + method, 509 + url, 510 + expectedJkt = null, 511 + accessToken = null, 512 + ) { 513 + const parts = proof.split('.'); 514 + if (parts.length !== 3) throw new Error('Invalid DPoP proof format'); 515 + 516 + const header = JSON.parse( 517 + new TextDecoder().decode(base64UrlDecode(parts[0])), 518 + ); 519 + const payload = JSON.parse( 520 + new TextDecoder().decode(base64UrlDecode(parts[1])), 521 + ); 522 + 523 + if (header.typ !== 'dpop+jwt') 524 + throw new Error('DPoP proof must have typ dpop+jwt'); 525 + if (header.alg !== 'ES256') throw new Error('DPoP proof must use ES256'); 526 + if (!header.jwk || header.jwk.kty !== 'EC') 527 + throw new Error('DPoP proof must contain EC key'); 528 + 529 + // Verify signature 530 + const publicKey = await crypto.subtle.importKey( 531 + 'jwk', 532 + header.jwk, 533 + { name: 'ECDSA', namedCurve: 'P-256' }, 534 + false, 535 + ['verify'], 536 + ); 537 + 538 + const signatureInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); 539 + const signature = base64UrlDecode(parts[2]); 540 + 541 + const valid = await crypto.subtle.verify( 542 + { name: 'ECDSA', hash: 'SHA-256' }, 543 + publicKey, 544 + /** @type {BufferSource} */ (signature), 545 + /** @type {BufferSource} */ (signatureInput), 546 + ); 547 + if (!valid) throw new Error('DPoP proof signature invalid'); 548 + 549 + // Validate claims 550 + if (payload.htm !== method) throw new Error('DPoP htm mismatch'); 551 + 552 + /** @param {string} u */ 553 + const normalizeUrl = (u) => u.replace(/\/$/, '').split('?')[0].toLowerCase(); 554 + if (normalizeUrl(payload.htu) !== normalizeUrl(url)) 555 + throw new Error('DPoP htu mismatch'); 556 + 557 + const now = Math.floor(Date.now() / 1000); 558 + if (!payload.iat || payload.iat > now + 60 || payload.iat < now - 300) { 559 + throw new Error('DPoP proof expired or invalid iat'); 560 + } 561 + 562 + if (!payload.jti) throw new Error('DPoP proof missing jti'); 563 + 564 + const jkt = await computeJwkThumbprint(header.jwk); 565 + if (expectedJkt && jkt !== expectedJkt) throw new Error('DPoP key mismatch'); 566 + 567 + if (accessToken) { 568 + const tokenHash = await crypto.subtle.digest( 569 + 'SHA-256', 570 + new TextEncoder().encode(accessToken), 571 + ); 572 + const expectedAth = base64UrlEncode(new Uint8Array(tokenHash)); 573 + if (payload.ath !== expectedAth) throw new Error('DPoP ath mismatch'); 574 + } 575 + 576 + return { jkt, jti: payload.jti, iat: payload.iat, jwk: header.jwk }; 577 + } 578 + /** 579 + * Render the OAuth consent page HTML. 580 + * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params 581 + * @returns {string} HTML page content 582 + */ 583 + function renderConsentPage({ 584 + clientName, 585 + clientId, 586 + scope, 587 + requestUri, 588 + error = '', 589 + }) { 590 + /** @param {string} s */ 591 + const escHtml = (s) => 592 + s 593 + .replace(/&/g, '&amp;') 594 + .replace(/</g, '&lt;') 595 + .replace(/>/g, '&gt;') 596 + .replace(/"/g, '&quot;'); 597 + return `<!DOCTYPE html> 598 + <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> 599 + <title>Authorize</title> 600 + <style> 601 + *{box-sizing:border-box} 602 + body{font-family:system-ui,sans-serif;max-width:400px;margin:40px auto;padding:20px;background:#1a1a1a;color:#e0e0e0} 603 + h2{color:#fff;margin-bottom:24px} 604 + p{color:#b0b0b0;line-height:1.5} 605 + b{color:#fff} 606 + .error{color:#ff6b6b;background:#2d1f1f;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #4a2020} 607 + label{display:block;margin:16px 0 6px;color:#b0b0b0;font-size:14px} 608 + input[type="password"]{width:100%;padding:12px;background:#2a2a2a;border:1px solid #404040;border-radius:6px;color:#fff;font-size:16px} 609 + input[type="password"]:focus{outline:none;border-color:#4a9eff;box-shadow:0 0 0 2px rgba(74,158,255,0.2)} 610 + .actions{display:flex;gap:12px;margin-top:24px} 611 + button{flex:1;padding:12px 20px;border-radius:6px;font-size:16px;font-weight:500;cursor:pointer;transition:background 0.15s} 612 + .deny{background:#2a2a2a;color:#e0e0e0;border:1px solid #404040} 613 + .deny:hover{background:#333} 614 + .approve{background:#2563eb;color:#fff;border:none} 615 + .approve:hover{background:#1d4ed8} 616 + </style></head> 617 + <body><h2>Sign in to authorize</h2> 618 + <p><b>${escHtml(clientName)}</b> wants to access your account.</p> 619 + <p>Scope: ${escHtml(scope)}</p> 620 + ${error ? `<p class="error">${escHtml(error)}</p>` : ''} 621 + <form method="POST" action="/oauth/authorize"> 622 + <input type="hidden" name="request_uri" value="${escHtml(requestUri)}"> 623 + <input type="hidden" name="client_id" value="${escHtml(clientId)}"> 624 + <label>Password</label><input type="password" name="password" required autofocus> 625 + <div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button> 626 + <button type="submit" name="action" value="approve" class="approve">Authorize</button></div> 627 + </form></body></html>`; 273 628 } 274 629 275 630 /** ··· 1383 1738 '/repo-info': { 1384 1739 handler: (pds, _req, _url) => pds.handleRepoInfo(), 1385 1740 }, 1741 + '/oauth-public-key': { 1742 + handler: async (pds) => Response.json(await pds.getPublicKeyJwk()), 1743 + }, 1744 + '/check-dpop-jti': { 1745 + method: 'POST', 1746 + handler: async (pds, req) => { 1747 + const { jti, iat } = await req.json(); 1748 + const fresh = pds.checkAndStoreDpopJti(jti, iat); 1749 + return Response.json({ fresh }); 1750 + }, 1751 + }, 1386 1752 '/xrpc/com.atproto.server.describeServer': { 1387 1753 handler: (pds, req, _url) => pds.handleDescribeServer(req), 1388 1754 }, ··· 1457 1823 '/xrpc/com.atproto.sync.subscribeRepos': { 1458 1824 handler: (pds, req, url) => pds.handleSubscribeRepos(req, url), 1459 1825 }, 1826 + // OAuth endpoints 1827 + '/.well-known/oauth-authorization-server': { 1828 + handler: (pds, _req, url) => pds.handleOAuthAuthServerMetadata(url), 1829 + }, 1830 + '/.well-known/oauth-protected-resource': { 1831 + handler: (pds, _req, url) => pds.handleOAuthProtectedResource(url), 1832 + }, 1833 + '/oauth/jwks': { 1834 + handler: (pds, _req, _url) => pds.handleOAuthJwks(), 1835 + }, 1836 + '/oauth/par': { 1837 + method: 'POST', 1838 + handler: (pds, req, url) => pds.handleOAuthPar(req, url), 1839 + }, 1840 + '/oauth/authorize': { 1841 + handler: (pds, req, url) => pds.handleOAuthAuthorize(req, url), 1842 + }, 1843 + '/oauth/token': { 1844 + method: 'POST', 1845 + handler: (pds, req, url) => pds.handleOAuthToken(req, url), 1846 + }, 1847 + '/oauth/revoke': { 1848 + method: 'POST', 1849 + handler: (pds, req, url) => pds.handleOAuthRevoke(req, url), 1850 + }, 1460 1851 }; 1461 1852 1462 1853 // ╔══════════════════════════════════════════════════════════════════════════════╗ ··· 1522 1913 CREATE INDEX IF NOT EXISTS idx_record_blobs_record_uri ON record_blobs(record_uri); 1523 1914 1524 1915 CREATE INDEX IF NOT EXISTS idx_records_collection ON records(collection, rkey); 1916 + 1917 + CREATE TABLE IF NOT EXISTS authorization_requests ( 1918 + id TEXT PRIMARY KEY, 1919 + client_id TEXT NOT NULL, 1920 + client_metadata TEXT NOT NULL, 1921 + parameters TEXT NOT NULL, 1922 + code TEXT, 1923 + code_challenge TEXT, 1924 + code_challenge_method TEXT, 1925 + dpop_jkt TEXT, 1926 + did TEXT, 1927 + expires_at TEXT NOT NULL, 1928 + created_at TEXT NOT NULL 1929 + ); 1930 + 1931 + CREATE INDEX IF NOT EXISTS idx_authorization_requests_code 1932 + ON authorization_requests(code) WHERE code IS NOT NULL; 1933 + 1934 + CREATE TABLE IF NOT EXISTS tokens ( 1935 + id INTEGER PRIMARY KEY AUTOINCREMENT, 1936 + token_id TEXT UNIQUE NOT NULL, 1937 + did TEXT NOT NULL, 1938 + client_id TEXT NOT NULL, 1939 + scope TEXT, 1940 + dpop_jkt TEXT, 1941 + expires_at TEXT NOT NULL, 1942 + refresh_token TEXT UNIQUE, 1943 + created_at TEXT NOT NULL, 1944 + updated_at TEXT NOT NULL 1945 + ); 1946 + 1947 + CREATE INDEX IF NOT EXISTS idx_tokens_did ON tokens(did); 1948 + 1949 + CREATE TABLE IF NOT EXISTS dpop_jtis ( 1950 + jti TEXT PRIMARY KEY, 1951 + expires_at TEXT NOT NULL 1952 + ); 1953 + 1954 + CREATE INDEX IF NOT EXISTS idx_dpop_jtis_expires ON dpop_jtis(expires_at); 1525 1955 `); 1526 1956 } 1527 1957 ··· 2120 2550 ); 2121 2551 } 2122 2552 2123 - // Check password against env var 2553 + // Check password against env var (timing-safe comparison) 2124 2554 const expectedPassword = this.env?.PDS_PASSWORD; 2125 - if (!expectedPassword || password !== expectedPassword) { 2555 + if ( 2556 + !expectedPassword || 2557 + !(await timingSafeEqual(password, expectedPassword)) 2558 + ) { 2126 2559 return errorResponse( 2127 2560 'AuthRequired', 2128 2561 'Invalid identifier or password', ··· 2170 2603 /** @param {Request} request */ 2171 2604 async handleGetSession(request) { 2172 2605 const authHeader = request.headers.get('Authorization'); 2173 - if (!authHeader || !authHeader.startsWith('Bearer ')) { 2606 + if (!authHeader) { 2174 2607 return errorResponse( 2175 2608 'AuthRequired', 2176 2609 'Missing or invalid authorization header', ··· 2178 2611 ); 2179 2612 } 2180 2613 2181 - const token = authHeader.slice(7); // Remove 'Bearer ' 2182 - const jwtSecret = this.env?.JWT_SECRET; 2183 - if (!jwtSecret) { 2614 + let did; 2615 + 2616 + // OAuth DPoP token 2617 + if (authHeader.startsWith('DPoP ')) { 2618 + try { 2619 + const result = await verifyOAuthAccessToken( 2620 + request, 2621 + authHeader.slice(5), 2622 + this, 2623 + ); 2624 + did = result.did; 2625 + } catch (err) { 2626 + const message = err instanceof Error ? err.message : String(err); 2627 + return errorResponse('InvalidToken', message, 401); 2628 + } 2629 + } 2630 + // Legacy Bearer token 2631 + else if (authHeader.startsWith('Bearer ')) { 2632 + const token = authHeader.slice(7); 2633 + const jwtSecret = this.env?.JWT_SECRET; 2634 + if (!jwtSecret) { 2635 + return errorResponse( 2636 + 'InternalServerError', 2637 + 'Server not configured for authentication', 2638 + 500, 2639 + ); 2640 + } 2641 + 2642 + try { 2643 + const payload = await verifyAccessJwt(token, jwtSecret); 2644 + did = payload.sub; 2645 + } catch (err) { 2646 + const message = err instanceof Error ? err.message : String(err); 2647 + return errorResponse('InvalidToken', message, 401); 2648 + } 2649 + } else { 2184 2650 return errorResponse( 2185 - 'InternalServerError', 2186 - 'Server not configured for authentication', 2187 - 500, 2651 + 'AuthRequired', 2652 + 'Invalid authorization header format', 2653 + 401, 2188 2654 ); 2189 2655 } 2190 2656 2191 - try { 2192 - const payload = await verifyAccessJwt(token, jwtSecret); 2193 - const did = payload.sub; 2194 - const handle = await this.getHandleForDid(did); 2195 - 2196 - return Response.json({ 2197 - handle: handle || did, 2198 - did, 2199 - active: true, 2200 - }); 2201 - } catch (err) { 2202 - const message = err instanceof Error ? err.message : String(err); 2203 - return errorResponse('InvalidToken', message, 401); 2204 - } 2657 + const handle = await this.getHandleForDid(did); 2658 + return Response.json({ 2659 + handle: handle || did, 2660 + did, 2661 + active: true, 2662 + }); 2205 2663 } 2206 2664 2207 2665 /** @param {Request} request */ ··· 2425 2883 try { 2426 2884 const result = await this.deleteRecord(body.collection, body.rkey); 2427 2885 if (result.error) { 2428 - return Response.json(result, { status: 404 }); 2886 + return errorResponse(result.error, result.message, 404); 2429 2887 } 2430 2888 return Response.json({}); 2431 2889 } catch (err) { ··· 3061 3519 this.sql.exec('DELETE FROM blobs WHERE cid = ?', cid); 3062 3520 } 3063 3521 } 3522 + 3523 + // ╔═════════════════════════════════════════════════════════════════════════════╗ 3524 + // ║ OAUTH HANDLERS ║ 3525 + // ║ OAuth 2.0 authorization server with DPoP, PKCE, and token management ║ 3526 + // ╚═════════════════════════════════════════════════════════════════════════════╝ 3527 + 3528 + /** 3529 + * Check if a DPoP jti has been used and mark it as used. 3530 + * Returns true if the jti is fresh (not seen before), false if it's a replay. 3531 + * Also cleans up expired jtis. 3532 + * @param {string} jti - The DPoP proof jti to check 3533 + * @param {number} iat - The iat claim from the DPoP proof (unix timestamp) 3534 + * @returns {boolean} True if jti is fresh, false if replay 3535 + */ 3536 + checkAndStoreDpopJti(jti, iat) { 3537 + // Clean up expired jtis (older than 5 minutes) 3538 + const cutoff = new Date(Date.now() - 5 * 60 * 1000).toISOString(); 3539 + this.sql.exec(`DELETE FROM dpop_jtis WHERE expires_at < ?`, cutoff); 3540 + 3541 + // Check if jti already exists 3542 + const existing = this.sql 3543 + .exec(`SELECT 1 FROM dpop_jtis WHERE jti = ?`, jti) 3544 + .toArray(); 3545 + if (existing.length > 0) { 3546 + return false; // Replay attack 3547 + } 3548 + 3549 + // Store jti with expiration (iat + 5 minutes) 3550 + const expiresAt = new Date((iat + 300) * 1000).toISOString(); 3551 + this.sql.exec( 3552 + `INSERT INTO dpop_jtis (jti, expires_at) VALUES (?, ?)`, 3553 + jti, 3554 + expiresAt, 3555 + ); 3556 + return true; 3557 + } 3558 + 3559 + /** 3560 + * Clean up expired authorization requests. 3561 + * Should be called periodically to prevent table bloat. 3562 + * @returns {number} Number of expired requests deleted 3563 + */ 3564 + cleanupExpiredAuthorizationRequests() { 3565 + const now = new Date().toISOString(); 3566 + const result = this.sql.exec( 3567 + `DELETE FROM authorization_requests WHERE expires_at < ?`, 3568 + now, 3569 + ); 3570 + return result.rowsWritten; 3571 + } 3572 + 3573 + /** 3574 + * Validate a required DPoP proof header, parse it, and check for replay attacks. 3575 + * @param {Request} request - The incoming request 3576 + * @param {string} method - Expected HTTP method 3577 + * @param {string} uri - Expected request URI 3578 + * @returns {Promise<{ dpop: DpopProofResult } | { error: Response }>} The parsed DPoP proof or error response 3579 + */ 3580 + async validateRequiredDpop(request, method, uri) { 3581 + const dpopHeader = request.headers.get('DPoP'); 3582 + if (!dpopHeader) { 3583 + return { 3584 + error: errorResponse('invalid_dpop_proof', 'DPoP proof required', 400), 3585 + }; 3586 + } 3587 + 3588 + let dpop; 3589 + try { 3590 + dpop = await parseDpopProof(dpopHeader, method, uri); 3591 + } catch (err) { 3592 + return { error: errorResponse('invalid_dpop_proof', err.message, 400) }; 3593 + } 3594 + 3595 + if (!this.checkAndStoreDpopJti(dpop.jti, dpop.iat)) { 3596 + return { 3597 + error: errorResponse( 3598 + 'invalid_dpop_proof', 3599 + 'DPoP proof replay detected', 3600 + 400, 3601 + ), 3602 + }; 3603 + } 3604 + 3605 + return { dpop }; 3606 + } 3607 + 3608 + /** 3609 + * Get or create the OAuth signing key for this PDS instance. 3610 + * Lazily generates a new key if one doesn't exist. 3611 + * @returns {Promise<string>} The private key as hex string 3612 + */ 3613 + async getOAuthPrivateKey() { 3614 + let privateKeyHex = /** @type {string|undefined} */ ( 3615 + await this.state.storage.get('oauthPrivateKey') 3616 + ); 3617 + if (!privateKeyHex) { 3618 + // Generate a new OAuth signing key 3619 + const keyPair = await crypto.subtle.generateKey( 3620 + { name: 'ECDSA', namedCurve: 'P-256' }, 3621 + true, 3622 + ['sign', 'verify'], 3623 + ); 3624 + const rawKey = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey); 3625 + // Extract the 32-byte private key from PKCS#8 (last 32 bytes after the prefix) 3626 + const keyBytes = new Uint8Array(rawKey).slice(-32); 3627 + privateKeyHex = bytesToHex(keyBytes); 3628 + await this.state.storage.put('oauthPrivateKey', privateKeyHex); 3629 + } 3630 + return privateKeyHex; 3631 + } 3632 + 3633 + /** 3634 + * Get the PDS signing key as a public JWK. 3635 + * Exports only the public components (kty, crv, x, y) for use in JWKS. 3636 + * @returns {Promise<{ kty: string, crv: string, x: string, y: string }>} The public key in JWK format 3637 + * @throws {Error} If the PDS is not initialized 3638 + */ 3639 + async getPublicKeyJwk() { 3640 + const privateKeyHex = await this.getOAuthPrivateKey(); 3641 + if (!privateKeyHex) throw new Error('PDS not initialized'); 3642 + 3643 + // Import key with extractable=true to export public components 3644 + const privateKeyBytes = hexToBytes(privateKeyHex); 3645 + const pkcs8Prefix = new Uint8Array([ 3646 + 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 3647 + 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 3648 + 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, 3649 + ]); 3650 + const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32); 3651 + pkcs8.set(pkcs8Prefix); 3652 + pkcs8.set(privateKeyBytes, pkcs8Prefix.length); 3653 + 3654 + const privateKey = await crypto.subtle.importKey( 3655 + 'pkcs8', 3656 + pkcs8, 3657 + { name: 'ECDSA', namedCurve: 'P-256' }, 3658 + true, 3659 + ['sign'], 3660 + ); 3661 + const jwk = await crypto.subtle.exportKey('jwk', privateKey); 3662 + return { 3663 + kty: /** @type {string} */ (jwk.kty), 3664 + crv: /** @type {string} */ (jwk.crv), 3665 + x: /** @type {string} */ (jwk.x), 3666 + y: /** @type {string} */ (jwk.y), 3667 + }; 3668 + } 3669 + 3670 + /** 3671 + * Handle OAuth Authorization Server Metadata endpoint. 3672 + * @param {URL} url - Parsed request URL 3673 + * @returns {Response} JSON response with OAuth AS metadata 3674 + */ 3675 + handleOAuthAuthServerMetadata(url) { 3676 + const issuer = `${url.protocol}//${url.host}`; 3677 + return Response.json({ 3678 + issuer, 3679 + authorization_endpoint: `${issuer}/oauth/authorize`, 3680 + token_endpoint: `${issuer}/oauth/token`, 3681 + revocation_endpoint: `${issuer}/oauth/revoke`, 3682 + pushed_authorization_request_endpoint: `${issuer}/oauth/par`, 3683 + jwks_uri: `${issuer}/oauth/jwks`, 3684 + scopes_supported: ['atproto'], 3685 + subject_types_supported: ['public'], 3686 + response_types_supported: ['code'], 3687 + response_modes_supported: ['query', 'fragment'], 3688 + grant_types_supported: ['authorization_code', 'refresh_token'], 3689 + code_challenge_methods_supported: ['S256'], 3690 + token_endpoint_auth_methods_supported: ['none'], 3691 + dpop_signing_alg_values_supported: ['ES256'], 3692 + require_pushed_authorization_requests: true, 3693 + authorization_response_iss_parameter_supported: true, 3694 + client_id_metadata_document_supported: true, 3695 + protected_resources: [issuer], 3696 + }); 3697 + } 3698 + 3699 + /** 3700 + * Handle OAuth Protected Resource Metadata endpoint. 3701 + * @param {URL} url - Parsed request URL 3702 + * @returns {Response} JSON response with OAuth PR metadata 3703 + */ 3704 + handleOAuthProtectedResource(url) { 3705 + const resource = `${url.protocol}//${url.host}`; 3706 + return Response.json({ 3707 + resource, 3708 + authorization_servers: [resource], 3709 + bearer_methods_supported: ['header'], 3710 + scopes_supported: ['atproto'], 3711 + }); 3712 + } 3713 + 3714 + /** 3715 + * Handle OAuth JWKS endpoint. 3716 + * @returns {Promise<Response>} JSON response with JWKS 3717 + */ 3718 + async handleOAuthJwks() { 3719 + const publicKeyJwk = await this.getPublicKeyJwk(); 3720 + return Response.json({ 3721 + keys: [ 3722 + { ...publicKeyJwk, kid: 'pds-oauth-key', use: 'sig', alg: 'ES256' }, 3723 + ], 3724 + }); 3725 + } 3726 + 3727 + /** 3728 + * Handle Pushed Authorization Request (PAR) endpoint. 3729 + * Validates DPoP proof, client metadata, PKCE parameters, and stores the authorization request. 3730 + * @param {Request} request - The incoming request 3731 + * @param {URL} url - Parsed request URL 3732 + * @returns {Promise<Response>} JSON response with request_uri and expires_in 3733 + */ 3734 + async handleOAuthPar(request, url) { 3735 + // Opportunistically clean up expired authorization requests 3736 + this.cleanupExpiredAuthorizationRequests(); 3737 + 3738 + const issuer = `${url.protocol}//${url.host}`; 3739 + 3740 + const dpopResult = await this.validateRequiredDpop( 3741 + request, 3742 + 'POST', 3743 + `${issuer}/oauth/par`, 3744 + ); 3745 + if ('error' in dpopResult) return dpopResult.error; 3746 + const { dpop } = dpopResult; 3747 + 3748 + // Parse body - support both JSON and form-encoded 3749 + /** @type {Record<string, string|undefined>} */ 3750 + let data; 3751 + try { 3752 + data = await parseRequestBody(request); 3753 + } catch { 3754 + return errorResponse('invalid_request', 'Invalid JSON body', 400); 3755 + } 3756 + 3757 + const clientId = data.client_id; 3758 + const redirectUri = data.redirect_uri; 3759 + const responseType = data.response_type; 3760 + const responseMode = data.response_mode; 3761 + const scope = data.scope; 3762 + const state = data.state; 3763 + const codeChallenge = data.code_challenge; 3764 + const codeChallengeMethod = data.code_challenge_method; 3765 + const loginHint = data.login_hint; 3766 + 3767 + if (!clientId) 3768 + return errorResponse('invalid_request', 'client_id required', 400); 3769 + if (!redirectUri) 3770 + return errorResponse('invalid_request', 'redirect_uri required', 400); 3771 + if (responseType !== 'code') 3772 + return errorResponse( 3773 + 'unsupported_response_type', 3774 + 'response_type must be code', 3775 + 400, 3776 + ); 3777 + if (!codeChallenge || codeChallengeMethod !== 'S256') { 3778 + return errorResponse('invalid_request', 'PKCE with S256 required', 400); 3779 + } 3780 + 3781 + let clientMetadata; 3782 + try { 3783 + clientMetadata = await getClientMetadata(clientId); 3784 + } catch (err) { 3785 + return errorResponse('invalid_client', err.message, 400); 3786 + } 3787 + 3788 + // Validate redirect_uri against registered URIs 3789 + // For loopback clients (RFC 8252), allow any path on the same origin 3790 + const isLoopback = 3791 + clientId.startsWith('http://localhost') || 3792 + clientId.startsWith('http://127.0.0.1'); 3793 + const redirectUriValid = clientMetadata.redirect_uris.some((uri) => { 3794 + if (isLoopback) { 3795 + // For loopback, check origin match (any path allowed) 3796 + try { 3797 + const registered = new URL(uri); 3798 + const requested = new URL(redirectUri); 3799 + return registered.origin === requested.origin; 3800 + } catch { 3801 + return false; 3802 + } 3803 + } 3804 + return uri === redirectUri; 3805 + }); 3806 + if (!redirectUriValid) { 3807 + return errorResponse( 3808 + 'invalid_request', 3809 + 'redirect_uri not registered for this client', 3810 + 400, 3811 + ); 3812 + } 3813 + 3814 + const requestId = crypto.randomUUID(); 3815 + const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; 3816 + const expiresIn = 600; 3817 + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); 3818 + 3819 + this.sql.exec( 3820 + `INSERT INTO authorization_requests ( 3821 + id, client_id, client_metadata, parameters, 3822 + code_challenge, code_challenge_method, dpop_jkt, 3823 + expires_at, created_at 3824 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 3825 + requestId, 3826 + clientId, 3827 + JSON.stringify(clientMetadata), 3828 + JSON.stringify({ 3829 + redirect_uri: redirectUri, 3830 + scope, 3831 + state, 3832 + response_mode: responseMode, 3833 + login_hint: loginHint, 3834 + }), 3835 + codeChallenge, 3836 + codeChallengeMethod, 3837 + dpop.jkt, 3838 + expiresAt, 3839 + new Date().toISOString(), 3840 + ); 3841 + 3842 + return Response.json({ request_uri: requestUri, expires_in: expiresIn }); 3843 + } 3844 + 3845 + /** 3846 + * Handle OAuth Authorize endpoint - displays consent UI (GET) or processes approval (POST). 3847 + * @param {Request} request - The incoming request 3848 + * @param {URL} url - Parsed request URL 3849 + * @returns {Promise<Response>} HTML consent page or redirect 3850 + */ 3851 + async handleOAuthAuthorize(request, url) { 3852 + if (request.method === 'GET') { 3853 + return this.handleOAuthAuthorizeGet(url); 3854 + } else if (request.method === 'POST') { 3855 + return this.handleOAuthAuthorizePost(request, url); 3856 + } 3857 + return errorResponse('MethodNotAllowed', 'Method not allowed', 405); 3858 + } 3859 + 3860 + /** 3861 + * Handle GET /oauth/authorize - displays the consent UI. 3862 + * Validates the request_uri from PAR and renders a login/consent form. 3863 + * @param {URL} url - Parsed request URL 3864 + * @returns {Promise<Response>} HTML consent page 3865 + */ 3866 + async handleOAuthAuthorizeGet(url) { 3867 + const requestUri = url.searchParams.get('request_uri'); 3868 + const clientId = url.searchParams.get('client_id'); 3869 + 3870 + if (!requestUri || !clientId) { 3871 + return new Response('Missing parameters', { status: 400 }); 3872 + } 3873 + 3874 + const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 3875 + if (!match) return new Response('Invalid request_uri', { status: 400 }); 3876 + 3877 + const rows = this.sql 3878 + .exec( 3879 + `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 3880 + match[1], 3881 + clientId, 3882 + ) 3883 + .toArray(); 3884 + const authRequest = rows[0]; 3885 + 3886 + if (!authRequest) return new Response('Request not found', { status: 400 }); 3887 + if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date()) 3888 + return new Response('Request expired', { status: 400 }); 3889 + if (authRequest.code) 3890 + return new Response('Request already used', { status: 400 }); 3891 + 3892 + const clientMetadata = JSON.parse( 3893 + /** @type {string} */ (authRequest.client_metadata), 3894 + ); 3895 + const parameters = JSON.parse( 3896 + /** @type {string} */ (authRequest.parameters), 3897 + ); 3898 + 3899 + return new Response( 3900 + renderConsentPage({ 3901 + clientName: clientMetadata.client_name || clientId, 3902 + clientId: clientId || '', 3903 + scope: parameters.scope || 'atproto', 3904 + requestUri: requestUri || '', 3905 + }), 3906 + { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 3907 + ); 3908 + } 3909 + 3910 + /** 3911 + * Handle POST /oauth/authorize - processes user approval/denial. 3912 + * Validates password, generates authorization code on approval, redirects to client. 3913 + * @param {Request} request - The incoming request 3914 + * @param {URL} url - Parsed request URL 3915 + * @returns {Promise<Response>} Redirect to client redirect_uri with code or error 3916 + */ 3917 + async handleOAuthAuthorizePost(request, url) { 3918 + const issuer = `${url.protocol}//${url.host}`; 3919 + const body = await request.text(); 3920 + const params = new URLSearchParams(body); 3921 + 3922 + const requestUri = params.get('request_uri'); 3923 + const clientId = params.get('client_id'); 3924 + const password = params.get('password'); 3925 + const action = params.get('action'); 3926 + 3927 + const match = requestUri?.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 3928 + if (!match) return new Response('Invalid request_uri', { status: 400 }); 3929 + 3930 + const authRows = this.sql 3931 + .exec( 3932 + `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 3933 + match[1], 3934 + clientId, 3935 + ) 3936 + .toArray(); 3937 + const authRequest = authRows[0]; 3938 + if (!authRequest) return new Response('Request not found', { status: 400 }); 3939 + 3940 + const clientMetadata = JSON.parse( 3941 + /** @type {string} */ (authRequest.client_metadata), 3942 + ); 3943 + const parameters = JSON.parse( 3944 + /** @type {string} */ (authRequest.parameters), 3945 + ); 3946 + 3947 + if (action === 'deny') { 3948 + this.sql.exec( 3949 + `DELETE FROM authorization_requests WHERE id = ?`, 3950 + match[1], 3951 + ); 3952 + const errorUrl = new URL(parameters.redirect_uri); 3953 + errorUrl.searchParams.set('error', 'access_denied'); 3954 + if (parameters.state) 3955 + errorUrl.searchParams.set('state', parameters.state); 3956 + errorUrl.searchParams.set('iss', issuer); 3957 + return Response.redirect(errorUrl.toString(), 302); 3958 + } 3959 + 3960 + // Timing-safe password comparison 3961 + const expectedPwd = this.env?.PDS_PASSWORD; 3962 + const passwordValid = 3963 + password && expectedPwd && (await timingSafeEqual(password, expectedPwd)); 3964 + if (!passwordValid) { 3965 + return new Response( 3966 + renderConsentPage({ 3967 + clientName: clientMetadata.client_name || clientId, 3968 + clientId: clientId || '', 3969 + scope: parameters.scope || 'atproto', 3970 + requestUri: requestUri || '', 3971 + error: 'Invalid password', 3972 + }), 3973 + { 3974 + status: 200, 3975 + headers: { 'Content-Type': 'text/html; charset=utf-8' }, 3976 + }, 3977 + ); 3978 + } 3979 + 3980 + const code = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); 3981 + 3982 + // Resolve DID from login_hint (can be handle or DID) 3983 + let did = parameters.login_hint; 3984 + if (did && !did.startsWith('did:')) { 3985 + // It's a handle, resolve to DID 3986 + const handleMap = /** @type {Record<string, string>} */ ( 3987 + (await this.state.storage.get('handleMap')) || {} 3988 + ); 3989 + did = handleMap[did]; 3990 + } 3991 + if (!did) { 3992 + return new Response('Could not resolve user', { status: 400 }); 3993 + } 3994 + 3995 + this.sql.exec( 3996 + `UPDATE authorization_requests SET code = ?, did = ? WHERE id = ?`, 3997 + code, 3998 + did, 3999 + match[1], 4000 + ); 4001 + 4002 + const successUrl = new URL(parameters.redirect_uri); 4003 + if (parameters.response_mode === 'fragment') { 4004 + const fragParams = new URLSearchParams(); 4005 + fragParams.set('code', code); 4006 + if (parameters.state) fragParams.set('state', parameters.state); 4007 + fragParams.set('iss', issuer); 4008 + successUrl.hash = fragParams.toString(); 4009 + } else { 4010 + successUrl.searchParams.set('code', code); 4011 + if (parameters.state) 4012 + successUrl.searchParams.set('state', parameters.state); 4013 + successUrl.searchParams.set('iss', issuer); 4014 + } 4015 + return Response.redirect(successUrl.toString(), 302); 4016 + } 4017 + 4018 + /** 4019 + * Handle token endpoint - exchanges authorization codes for tokens. 4020 + * Supports authorization_code and refresh_token grant types. 4021 + * @param {Request} request - The incoming request 4022 + * @param {URL} url - Parsed request URL 4023 + * @returns {Promise<Response>} JSON response with access_token, token_type, expires_in, refresh_token, scope 4024 + */ 4025 + async handleOAuthToken(request, url) { 4026 + const issuer = `${url.protocol}//${url.host}`; 4027 + 4028 + const dpopResult = await this.validateRequiredDpop( 4029 + request, 4030 + 'POST', 4031 + `${issuer}/oauth/token`, 4032 + ); 4033 + if ('error' in dpopResult) return dpopResult.error; 4034 + const { dpop } = dpopResult; 4035 + 4036 + const contentType = request.headers.get('content-type') || ''; 4037 + const body = await request.text(); 4038 + /** @type {URLSearchParams | Map<string, string>} */ 4039 + let params; 4040 + if (contentType.includes('application/json')) { 4041 + try { 4042 + const json = JSON.parse(body); 4043 + params = new Map(Object.entries(json)); 4044 + } catch { 4045 + return errorResponse('invalid_request', 'Invalid JSON body', 400); 4046 + } 4047 + } else { 4048 + params = new URLSearchParams(body); 4049 + } 4050 + const grantType = params.get('grant_type'); 4051 + 4052 + if (grantType === 'authorization_code') { 4053 + return this.handleAuthCodeGrant(params, dpop, issuer); 4054 + } else if (grantType === 'refresh_token') { 4055 + return this.handleRefreshGrant(params, dpop, issuer); 4056 + } 4057 + return errorResponse( 4058 + 'unsupported_grant_type', 4059 + 'Grant type not supported', 4060 + 400, 4061 + ); 4062 + } 4063 + 4064 + /** 4065 + * Handle authorization_code grant type. 4066 + * Validates the code, PKCE verifier, and DPoP binding, then issues tokens. 4067 + * @param {URLSearchParams | Map<string, string>} params - Token request parameters 4068 + * @param {DpopProofResult} dpop - Parsed DPoP proof 4069 + * @param {string} issuer - The PDS issuer URL 4070 + * @returns {Promise<Response>} JSON token response 4071 + */ 4072 + async handleAuthCodeGrant(params, dpop, issuer) { 4073 + const code = params.get('code'); 4074 + const redirectUri = params.get('redirect_uri'); 4075 + const clientId = params.get('client_id'); 4076 + const codeVerifier = params.get('code_verifier'); 4077 + 4078 + if (!code || !redirectUri || !clientId || !codeVerifier) { 4079 + return errorResponse( 4080 + 'invalid_request', 4081 + 'Missing required parameters', 4082 + 400, 4083 + ); 4084 + } 4085 + 4086 + const authRows = this.sql 4087 + .exec(`SELECT * FROM authorization_requests WHERE code = ?`, code) 4088 + .toArray(); 4089 + const authRequest = authRows[0]; 4090 + if (!authRequest) 4091 + return errorResponse('invalid_grant', 'Invalid code', 400); 4092 + if (authRequest.client_id !== clientId) 4093 + return errorResponse('invalid_grant', 'Client mismatch', 400); 4094 + if (authRequest.dpop_jkt !== dpop.jkt) 4095 + return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 4096 + 4097 + const parameters = JSON.parse( 4098 + /** @type {string} */ (authRequest.parameters), 4099 + ); 4100 + if (parameters.redirect_uri !== redirectUri) 4101 + return errorResponse('invalid_grant', 'Redirect URI mismatch', 400); 4102 + 4103 + // Verify PKCE 4104 + const challengeHash = await crypto.subtle.digest( 4105 + 'SHA-256', 4106 + new TextEncoder().encode(codeVerifier), 4107 + ); 4108 + const computedChallenge = base64UrlEncode(new Uint8Array(challengeHash)); 4109 + if (computedChallenge !== authRequest.code_challenge) { 4110 + return errorResponse('invalid_grant', 'Invalid code_verifier', 400); 4111 + } 4112 + 4113 + this.sql.exec( 4114 + `DELETE FROM authorization_requests WHERE id = ?`, 4115 + authRequest.id, 4116 + ); 4117 + 4118 + const tokenId = crypto.randomUUID(); 4119 + const refreshToken = base64UrlEncode( 4120 + crypto.getRandomValues(new Uint8Array(32)), 4121 + ); 4122 + const scope = parameters.scope || 'atproto'; 4123 + const now = new Date(); 4124 + const expiresIn = 3600; 4125 + const subjectDid = /** @type {string} */ (authRequest.did); 4126 + 4127 + const accessToken = await this.createOAuthAccessToken({ 4128 + issuer, 4129 + subject: subjectDid, 4130 + clientId, 4131 + scope, 4132 + tokenId, 4133 + dpopJkt: dpop.jkt, 4134 + expiresIn, 4135 + }); 4136 + 4137 + this.sql.exec( 4138 + `INSERT INTO tokens (token_id, did, client_id, scope, dpop_jkt, expires_at, refresh_token, created_at, updated_at) 4139 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 4140 + tokenId, 4141 + subjectDid, 4142 + clientId, 4143 + scope, 4144 + dpop.jkt, 4145 + new Date(now.getTime() + expiresIn * 1000).toISOString(), 4146 + refreshToken, 4147 + now.toISOString(), 4148 + now.toISOString(), 4149 + ); 4150 + 4151 + return Response.json({ 4152 + access_token: accessToken, 4153 + token_type: 'DPoP', 4154 + expires_in: expiresIn, 4155 + refresh_token: refreshToken, 4156 + scope, 4157 + sub: subjectDid, 4158 + }); 4159 + } 4160 + 4161 + /** 4162 + * Handle refresh_token grant type. 4163 + * Validates the refresh token, DPoP binding, and 24hr lifetime, then rotates tokens. 4164 + * @param {URLSearchParams | Map<string, string>} params - Token request parameters 4165 + * @param {DpopProofResult} dpop - Parsed DPoP proof 4166 + * @param {string} issuer - The PDS issuer URL 4167 + * @returns {Promise<Response>} JSON token response with new access and refresh tokens 4168 + */ 4169 + async handleRefreshGrant(params, dpop, issuer) { 4170 + const refreshToken = params.get('refresh_token'); 4171 + const clientId = params.get('client_id'); 4172 + 4173 + if (!refreshToken || !clientId) 4174 + return errorResponse( 4175 + 'invalid_request', 4176 + 'Missing required parameters', 4177 + 400, 4178 + ); 4179 + 4180 + const tokenRows = this.sql 4181 + .exec(`SELECT * FROM tokens WHERE refresh_token = ?`, refreshToken) 4182 + .toArray(); 4183 + const token = tokenRows[0]; 4184 + 4185 + if (!token) 4186 + return errorResponse('invalid_grant', 'Invalid refresh token', 400); 4187 + if (token.client_id !== clientId) 4188 + return errorResponse('invalid_grant', 'Client mismatch', 400); 4189 + if (token.dpop_jkt !== dpop.jkt) 4190 + return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 4191 + 4192 + // Check 24hr lifetime 4193 + const createdAt = new Date(/** @type {string} */ (token.created_at)); 4194 + if (Date.now() - createdAt.getTime() > 24 * 60 * 60 * 1000) { 4195 + this.sql.exec(`DELETE FROM tokens WHERE token_id = ?`, token.token_id); 4196 + return errorResponse('invalid_grant', 'Refresh token expired', 400); 4197 + } 4198 + 4199 + const newTokenId = crypto.randomUUID(); 4200 + const newRefreshToken = base64UrlEncode( 4201 + crypto.getRandomValues(new Uint8Array(32)), 4202 + ); 4203 + const now = new Date(); 4204 + const expiresIn = 3600; 4205 + const tokenDid = /** @type {string} */ (token.did); 4206 + const tokenScope = /** @type {string} */ (token.scope); 4207 + 4208 + const accessToken = await this.createOAuthAccessToken({ 4209 + issuer, 4210 + subject: tokenDid, 4211 + clientId, 4212 + scope: tokenScope, 4213 + tokenId: newTokenId, 4214 + dpopJkt: dpop.jkt, 4215 + expiresIn, 4216 + }); 4217 + 4218 + this.sql.exec( 4219 + `UPDATE tokens SET token_id = ?, refresh_token = ?, expires_at = ?, updated_at = ? WHERE token_id = ?`, 4220 + newTokenId, 4221 + newRefreshToken, 4222 + new Date(now.getTime() + expiresIn * 1000).toISOString(), 4223 + now.toISOString(), 4224 + token.token_id, 4225 + ); 4226 + 4227 + return Response.json({ 4228 + access_token: accessToken, 4229 + token_type: 'DPoP', 4230 + expires_in: expiresIn, 4231 + refresh_token: newRefreshToken, 4232 + scope: tokenScope, 4233 + sub: tokenDid, 4234 + }); 4235 + } 4236 + 4237 + /** 4238 + * Create a DPoP-bound access token (at+jwt). 4239 + * @param {AccessTokenParams} params 4240 + * @returns {Promise<string>} The signed JWT access token 4241 + */ 4242 + async createOAuthAccessToken({ 4243 + issuer, 4244 + subject, 4245 + clientId, 4246 + scope, 4247 + tokenId, 4248 + dpopJkt, 4249 + expiresIn, 4250 + }) { 4251 + const now = Math.floor(Date.now() / 1000); 4252 + const header = { typ: 'at+jwt', alg: 'ES256', kid: 'pds-oauth-key' }; 4253 + const payload = { 4254 + iss: issuer, 4255 + sub: subject, 4256 + aud: issuer, 4257 + client_id: clientId, 4258 + scope, 4259 + jti: tokenId, 4260 + iat: now, 4261 + exp: now + expiresIn, 4262 + cnf: { jkt: dpopJkt }, 4263 + }; 4264 + 4265 + const privateKeyHex = await this.getOAuthPrivateKey(); 4266 + const privateKey = await importPrivateKey(hexToBytes(privateKeyHex)); 4267 + 4268 + const headerB64 = base64UrlEncode( 4269 + new TextEncoder().encode(JSON.stringify(header)), 4270 + ); 4271 + const payloadB64 = base64UrlEncode( 4272 + new TextEncoder().encode(JSON.stringify(payload)), 4273 + ); 4274 + const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); 4275 + const sig = await sign(privateKey, sigInput); 4276 + 4277 + return `${headerB64}.${payloadB64}.${base64UrlEncode(sig)}`; 4278 + } 4279 + 4280 + /** 4281 + * Handle token revocation endpoint (RFC 7009). 4282 + * Revokes access tokens and refresh tokens by client_id. 4283 + * @param {Request} request - The incoming request 4284 + * @param {URL} url - Parsed request URL 4285 + * @returns {Promise<Response>} Empty 200 response on success 4286 + */ 4287 + async handleOAuthRevoke(request, url) { 4288 + const issuer = `${url.protocol}//${url.host}`; 4289 + 4290 + // Optional DPoP verification - if present, verify it 4291 + const dpopHeader = request.headers.get('DPoP'); 4292 + if (dpopHeader) { 4293 + try { 4294 + const dpop = await parseDpopProof( 4295 + dpopHeader, 4296 + 'POST', 4297 + `${issuer}/oauth/revoke`, 4298 + ); 4299 + // Check for DPoP replay attack 4300 + if (!this.checkAndStoreDpopJti(dpop.jti, dpop.iat)) { 4301 + return errorResponse( 4302 + 'invalid_dpop_proof', 4303 + 'DPoP proof replay detected', 4304 + 400, 4305 + ); 4306 + } 4307 + } catch (err) { 4308 + return errorResponse('invalid_dpop_proof', err.message, 400); 4309 + } 4310 + } 4311 + 4312 + /** @type {Record<string, string>} */ 4313 + let data; 4314 + try { 4315 + data = await parseRequestBody(request); 4316 + } catch { 4317 + return errorResponse('invalid_request', 'Invalid JSON body', 400); 4318 + } 4319 + 4320 + const validation = validateRequiredParams(data, ['token', 'client_id']); 4321 + if (!validation.valid) { 4322 + return errorResponse( 4323 + 'invalid_request', 4324 + 'Missing required parameters', 4325 + 400, 4326 + ); 4327 + } 4328 + const { token, client_id: clientId } = data; 4329 + 4330 + this.sql.exec( 4331 + `DELETE FROM tokens WHERE client_id = ? AND (refresh_token = ? OR token_id = ?)`, 4332 + clientId, 4333 + token, 4334 + token, 4335 + ); 4336 + 4337 + return new Response(null, { status: 200 }); 4338 + } 3064 4339 } 3065 4340 3066 4341 // ╔══════════════════════════════════════════════════════════════════════════════╗ ··· 3072 4347 'Access-Control-Allow-Origin': '*', 3073 4348 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 3074 4349 'Access-Control-Allow-Headers': 3075 - 'Content-Type, Authorization, atproto-accept-labelers, atproto-proxy, x-bsky-topics', 4350 + 'Content-Type, Authorization, DPoP, atproto-accept-labelers, atproto-proxy, x-bsky-topics', 3076 4351 }; 3077 4352 3078 4353 /** ··· 3129 4404 } 3130 4405 3131 4406 /** 3132 - * Verify auth and return DID from token 4407 + * Verify auth and return DID from token. 4408 + * Supports both legacy Bearer tokens (JWT with symmetric key) and OAuth DPoP tokens. 3133 4409 * @param {Request} request - HTTP request with Authorization header 3134 4410 * @param {Env} env - Environment with JWT_SECRET 3135 - * @returns {Promise<{did: string} | {error: Response}>} DID or error response 4411 + * @param {{ fetch: (req: Request) => Promise<Response> }} [pds] - PDS stub for OAuth token verification (optional) 4412 + * @returns {Promise<{did: string, scope?: string} | {error: Response}>} DID (and scope for OAuth) or error response 3136 4413 */ 3137 - async function requireAuth(request, env) { 4414 + async function requireAuth(request, env, pds = undefined) { 3138 4415 const authHeader = request.headers.get('Authorization'); 3139 - if (!authHeader || !authHeader.startsWith('Bearer ')) { 4416 + if (!authHeader) { 3140 4417 return { 3141 - error: Response.json( 3142 - { 3143 - error: 'AuthRequired', 3144 - message: 'Authentication required', 3145 - }, 3146 - { status: 401 }, 3147 - ), 4418 + error: errorResponse('AuthRequired', 'Authentication required', 401), 3148 4419 }; 3149 4420 } 3150 4421 3151 - const token = authHeader.slice(7); 3152 - const jwtSecret = env?.JWT_SECRET; 3153 - if (!jwtSecret) { 3154 - return { 3155 - error: Response.json( 3156 - { 3157 - error: 'InternalServerError', 3158 - message: 'Server not configured for authentication', 3159 - }, 3160 - { status: 500 }, 3161 - ), 3162 - }; 4422 + // Legacy Bearer token (symmetric JWT) 4423 + if (authHeader.startsWith('Bearer ')) { 4424 + const token = authHeader.slice(7); 4425 + const jwtSecret = env?.JWT_SECRET; 4426 + if (!jwtSecret) { 4427 + return { 4428 + error: errorResponse( 4429 + 'InternalServerError', 4430 + 'Server not configured for authentication', 4431 + 500, 4432 + ), 4433 + }; 4434 + } 4435 + 4436 + try { 4437 + const payload = await verifyAccessJwt(token, jwtSecret); 4438 + return { did: payload.sub }; 4439 + } catch (err) { 4440 + const message = err instanceof Error ? err.message : String(err); 4441 + return { error: errorResponse('InvalidToken', message, 401) }; 4442 + } 3163 4443 } 3164 4444 3165 - try { 3166 - const payload = await verifyAccessJwt(token, jwtSecret); 3167 - return { did: payload.sub }; 3168 - } catch (err) { 3169 - const message = err instanceof Error ? err.message : String(err); 3170 - return { 3171 - error: Response.json( 3172 - { 3173 - error: 'InvalidToken', 3174 - message: message, 3175 - }, 3176 - { status: 401 }, 3177 - ), 3178 - }; 4445 + // OAuth DPoP token 4446 + if (authHeader.startsWith('DPoP ')) { 4447 + if (!pds) { 4448 + return { 4449 + error: errorResponse( 4450 + 'InternalServerError', 4451 + 'DPoP tokens not supported on this endpoint', 4452 + 500, 4453 + ), 4454 + }; 4455 + } 4456 + 4457 + try { 4458 + const result = await verifyOAuthAccessToken( 4459 + request, 4460 + authHeader.slice(5), 4461 + pds, 4462 + ); 4463 + return result; 4464 + } catch (err) { 4465 + const message = err instanceof Error ? err.message : String(err); 4466 + return { error: errorResponse('InvalidToken', message, 401) }; 4467 + } 4468 + } 4469 + 4470 + return { 4471 + error: errorResponse('AuthRequired', 'Invalid authorization type', 401), 4472 + }; 4473 + } 4474 + 4475 + /** 4476 + * Verify an OAuth DPoP-bound access token. 4477 + * Validates the JWT signature, expiration, DPoP binding, and proof. 4478 + * @param {Request} request - The incoming request (for DPoP validation) 4479 + * @param {string} token - The access token JWT 4480 + * @param {{ fetch: (req: Request) => Promise<Response> }} pdsStub - The PDS stub with fetch method 4481 + * @returns {Promise<{did: string, scope?: string}>} The authenticated user's DID and scope 4482 + * @throws {Error} If verification fails 4483 + */ 4484 + async function verifyOAuthAccessToken(request, token, pdsStub) { 4485 + const parts = token.split('.'); 4486 + if (parts.length !== 3) throw new Error('Invalid token format'); 4487 + 4488 + const header = JSON.parse( 4489 + new TextDecoder().decode(base64UrlDecode(parts[0])), 4490 + ); 4491 + if (header.typ !== 'at+jwt') throw new Error('Invalid token type'); 4492 + 4493 + // Verify signature with PDS public key (fetch from DO) 4494 + const keyRes = await pdsStub.fetch( 4495 + new Request('http://internal/oauth-public-key'), 4496 + ); 4497 + const publicKeyJwk = await keyRes.json(); 4498 + const publicKey = await crypto.subtle.importKey( 4499 + 'jwk', 4500 + publicKeyJwk, 4501 + { name: 'ECDSA', namedCurve: 'P-256' }, 4502 + false, 4503 + ['verify'], 4504 + ); 4505 + 4506 + const signatureInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); 4507 + const signature = base64UrlDecode(parts[2]); 4508 + 4509 + const valid = await crypto.subtle.verify( 4510 + { name: 'ECDSA', hash: 'SHA-256' }, 4511 + publicKey, 4512 + /** @type {BufferSource} */ (signature), 4513 + /** @type {BufferSource} */ (signatureInput), 4514 + ); 4515 + if (!valid) throw new Error('Invalid token signature'); 4516 + 4517 + const payload = JSON.parse( 4518 + new TextDecoder().decode(base64UrlDecode(parts[1])), 4519 + ); 4520 + 4521 + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { 4522 + throw new Error('Token expired'); 3179 4523 } 4524 + 4525 + if (!payload.cnf?.jkt) throw new Error('Token missing DPoP binding'); 4526 + 4527 + const dpopHeader = request.headers.get('DPoP'); 4528 + if (!dpopHeader) throw new Error('DPoP proof required'); 4529 + 4530 + const url = new URL(request.url); 4531 + const dpop = await parseDpopProof( 4532 + dpopHeader, 4533 + request.method, 4534 + `${url.protocol}//${url.host}${url.pathname}`, 4535 + payload.cnf.jkt, 4536 + token, 4537 + ); 4538 + 4539 + // Check for DPoP jti replay 4540 + const jtiRes = await pdsStub.fetch( 4541 + new Request('http://internal/check-dpop-jti', { 4542 + method: 'POST', 4543 + body: JSON.stringify({ jti: dpop.jti, iat: dpop.iat }), 4544 + }), 4545 + ); 4546 + const { fresh } = await jtiRes.json(); 4547 + if (!fresh) throw new Error('DPoP proof replay detected'); 4548 + 4549 + return { did: payload.sub, scope: payload.scope }; 4550 + } 4551 + 4552 + /** 4553 + * Check if the token scope allows the requested operation. 4554 + * Legacy tokens (no scope) are always allowed; OAuth tokens must have 'atproto' scope. 4555 + * @param {string | undefined} scope - The token scope 4556 + * @param {string} requiredScope - The required scope (e.g., 'atproto') 4557 + * @returns {boolean} Whether the scope is sufficient 4558 + */ 4559 + function hasRequiredScope(scope, requiredScope) { 4560 + // Legacy tokens without scope are trusted for all operations 4561 + if (!scope) return true; 4562 + // Check if the scope includes the required scope 4563 + const scopes = scope.split(' '); 4564 + return scopes.includes(requiredScope); 3180 4565 } 3181 4566 3182 4567 /** ··· 3184 4569 * @param {Env} env 3185 4570 */ 3186 4571 async function handleAuthenticatedBlobUpload(request, env) { 3187 - const auth = await requireAuth(request, env); 4572 + // Get default PDS for OAuth token verification 4573 + const defaultPds = getDefaultPds(env); 4574 + const auth = await requireAuth(request, env, defaultPds); 3188 4575 if ('error' in auth) return auth.error; 3189 4576 4577 + // Validate scope for blob upload 4578 + if (!hasRequiredScope(auth.scope, 'atproto')) { 4579 + return errorResponse( 4580 + 'Forbidden', 4581 + 'Insufficient scope for blob upload', 4582 + 403, 4583 + ); 4584 + } 4585 + 3190 4586 // Route to the user's DO based on their DID from the token 3191 4587 const id = env.PDS.idFromName(auth.did); 3192 4588 const pds = env.PDS.get(id); ··· 3198 4594 * @param {Env} env 3199 4595 */ 3200 4596 async function handleAuthenticatedRepoWrite(request, env) { 3201 - const auth = await requireAuth(request, env); 4597 + // Get default PDS for OAuth token verification 4598 + const defaultPds = getDefaultPds(env); 4599 + const auth = await requireAuth(request, env, defaultPds); 3202 4600 if ('error' in auth) return auth.error; 3203 4601 4602 + // Validate scope for repo write 4603 + if (!hasRequiredScope(auth.scope, 'atproto')) { 4604 + return errorResponse('Forbidden', 'Insufficient scope for repo write', 403); 4605 + } 4606 + 3204 4607 const body = await request.json(); 3205 4608 const repo = body.repo; 3206 4609 if (!repo) { ··· 3243 4646 // Look up handle -> DID in default DO 3244 4647 // Use subdomain if present, otherwise try bare hostname as handle 3245 4648 const handleToResolve = subdomain || url.hostname; 3246 - const defaultId = env.PDS.idFromName('default'); 3247 - const defaultPds = env.PDS.get(defaultId); 4649 + const defaultPds = getDefaultPds(env); 3248 4650 const resolveRes = await defaultPds.fetch( 3249 4651 new Request( 3250 4652 `http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`, ··· 3259 4661 3260 4662 // describeServer - works on bare domain 3261 4663 if (url.pathname === '/xrpc/com.atproto.server.describeServer') { 3262 - const defaultId = env.PDS.idFromName('default'); 3263 - const defaultPds = env.PDS.get(defaultId); 4664 + const defaultPds = getDefaultPds(env); 3264 4665 const newReq = new Request(request.url, { 3265 4666 method: request.method, 3266 4667 headers: { ··· 3271 4672 return defaultPds.fetch(newReq); 3272 4673 } 3273 4674 3274 - // createSession - handle on default DO (has handleMap for identifier resolution) 3275 - if (url.pathname === '/xrpc/com.atproto.server.createSession') { 3276 - const defaultId = env.PDS.idFromName('default'); 3277 - const defaultPds = env.PDS.get(defaultId); 3278 - return defaultPds.fetch(request); 3279 - } 3280 - 3281 - // getSession - route to default DO 3282 - if (url.pathname === '/xrpc/com.atproto.server.getSession') { 3283 - const defaultId = env.PDS.idFromName('default'); 3284 - const defaultPds = env.PDS.get(defaultId); 3285 - return defaultPds.fetch(request); 3286 - } 3287 - 3288 - // refreshSession - route to default DO 3289 - if (url.pathname === '/xrpc/com.atproto.server.refreshSession') { 3290 - const defaultId = env.PDS.idFromName('default'); 3291 - const defaultPds = env.PDS.get(defaultId); 4675 + // Session endpoints - route to default DO (has handleMap for identifier resolution) 4676 + const sessionEndpoints = [ 4677 + '/xrpc/com.atproto.server.createSession', 4678 + '/xrpc/com.atproto.server.getSession', 4679 + '/xrpc/com.atproto.server.refreshSession', 4680 + ]; 4681 + if (sessionEndpoints.includes(url.pathname)) { 4682 + const defaultPds = getDefaultPds(env); 3292 4683 return defaultPds.fetch(request); 3293 4684 } 3294 4685 3295 4686 // Proxy app.bsky.* endpoints to Bluesky AppView 3296 4687 if (url.pathname.startsWith('/xrpc/app.bsky.')) { 4688 + // Get default PDS for OAuth token verification 4689 + const defaultPds = getDefaultPds(env); 3297 4690 // Authenticate the user first 3298 - const auth = await requireAuth(request, env); 4691 + const auth = await requireAuth(request, env, defaultPds); 3299 4692 if ('error' in auth) return auth.error; 3300 4693 3301 4694 // Route to the user's DO instance to create service auth and proxy ··· 3321 4714 url.pathname === '/register-handle' || 3322 4715 url.pathname === '/resolve-handle' 3323 4716 ) { 3324 - const defaultId = env.PDS.idFromName('default'); 3325 - const defaultPds = env.PDS.get(defaultId); 4717 + const defaultPds = getDefaultPds(env); 3326 4718 return defaultPds.fetch(request); 3327 4719 } 3328 4720 ··· 3332 4724 if (!handle) { 3333 4725 return errorResponse('InvalidRequest', 'missing handle param', 400); 3334 4726 } 3335 - const defaultId = env.PDS.idFromName('default'); 3336 - const defaultPds = env.PDS.get(defaultId); 4727 + const defaultPds = getDefaultPds(env); 3337 4728 const resolveRes = await defaultPds.fetch( 3338 4729 new Request( 3339 4730 `http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`, ··· 3348 4739 3349 4740 // subscribeRepos WebSocket - route to default instance for firehose 3350 4741 if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { 3351 - const defaultId = env.PDS.idFromName('default'); 3352 - const defaultPds = env.PDS.get(defaultId); 4742 + const defaultPds = getDefaultPds(env); 3353 4743 return defaultPds.fetch(request); 3354 4744 } 3355 4745 3356 4746 // listRepos needs to aggregate from all registered DIDs 3357 4747 if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { 3358 - const defaultId = env.PDS.idFromName('default'); 3359 - const defaultPds = env.PDS.get(defaultId); 4748 + const defaultPds = getDefaultPds(env); 3360 4749 const regRes = await defaultPds.fetch( 3361 4750 new Request('http://internal/get-registered-dids'), 3362 4751 ); ··· 3455 4844 const body = await request.json(); 3456 4845 3457 4846 // Register with default instance for discovery 3458 - const defaultId = env.PDS.idFromName('default'); 3459 - const defaultPds = env.PDS.get(defaultId); 4847 + const defaultPds = getDefaultPds(env); 3460 4848 await defaultPds.fetch( 3461 4849 new Request('http://internal/register-did', { 3462 4850 method: 'POST', ··· 3474 4862 ); 3475 4863 } 3476 4864 4865 + // Also initialize default instance with identity for OAuth (single-user PDS) 4866 + await defaultPds.fetch( 4867 + new Request('http://internal/init', { 4868 + method: 'POST', 4869 + body: JSON.stringify(body), 4870 + }), 4871 + ); 4872 + 3477 4873 // Forward to the actual PDS instance 3478 4874 const id = env.PDS.idFromName(did); 3479 4875 const pds = env.PDS.get(id); ··· 3484 4880 body: JSON.stringify(body), 3485 4881 }), 3486 4882 ); 4883 + } 4884 + 4885 + // OAuth endpoints - route to default PDS instance 4886 + const oauthEndpoints = [ 4887 + '/.well-known/oauth-authorization-server', 4888 + '/.well-known/oauth-protected-resource', 4889 + '/oauth/jwks', 4890 + '/oauth/par', 4891 + '/oauth/authorize', 4892 + '/oauth/token', 4893 + '/oauth/revoke', 4894 + ]; 4895 + if (oauthEndpoints.includes(url.pathname)) { 4896 + const defaultPds = getDefaultPds(env); 4897 + return defaultPds.fetch(request); 3487 4898 } 3488 4899 3489 4900 // Unknown endpoint
-295
test/e2e.sh
··· 1 - #!/bin/bash 2 - # E2E tests for PDS - runs against local wrangler dev 3 - set -e 4 - 5 - BASE="http://localhost:8787" 6 - # Generate unique test DID (or use env var for consistency) 7 - DID="${TEST_DID:-did:plc:test$(openssl rand -hex 8)}" 8 - 9 - # Helper for colored output 10 - pass() { echo "✓ $1"; } 11 - fail() { 12 - echo "✗ $1" >&2 13 - cleanup 14 - exit 1 15 - } 16 - 17 - # Cleanup function 18 - cleanup() { 19 - if [ -n "$WRANGLER_PID" ]; then 20 - echo "Shutting down wrangler..." 21 - kill $WRANGLER_PID 2>/dev/null || true 22 - wait $WRANGLER_PID 2>/dev/null || true 23 - fi 24 - } 25 - trap cleanup EXIT 26 - 27 - # Start wrangler dev with local R2 persistence 28 - echo "Starting wrangler dev..." 29 - npx wrangler dev --port 8787 --persist-to .wrangler/state >/dev/null 2>&1 & 30 - WRANGLER_PID=$! 31 - 32 - # Wait for server to be ready 33 - for i in {1..30}; do 34 - if curl -sf "$BASE/" >/dev/null 2>&1; then 35 - break 36 - fi 37 - sleep 0.5 38 - done 39 - 40 - # Verify server is up 41 - curl -sf "$BASE/" >/dev/null || fail "Server failed to start" 42 - pass "Server started" 43 - 44 - # Initialize PDS 45 - PRIVKEY=$(openssl rand -hex 32) 46 - curl -sf -X POST "$BASE/init?did=$DID" \ 47 - -H "Content-Type: application/json" \ 48 - -d "{\"did\":\"$DID\",\"privateKey\":\"$PRIVKEY\",\"handle\":\"test.local\"}" >/dev/null && 49 - pass "PDS initialized" || fail "PDS init" 50 - 51 - echo 52 - echo "Running tests..." 53 - echo 54 - 55 - # Root returns ASCII art 56 - curl -sf "$BASE/" | grep -q "PDS" && pass "Root returns ASCII art" || fail "Root" 57 - 58 - # describeServer works 59 - curl -sf "$BASE/xrpc/com.atproto.server.describeServer" | jq -e '.did' >/dev/null && 60 - pass "describeServer" || fail "describeServer" 61 - 62 - # resolveHandle works 63 - curl -sf "$BASE/xrpc/com.atproto.identity.resolveHandle?handle=test.local" | 64 - jq -e '.did' >/dev/null && pass "resolveHandle" || fail "resolveHandle" 65 - 66 - # createSession returns tokens 67 - SESSION=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 68 - -H "Content-Type: application/json" \ 69 - -d "{\"identifier\":\"$DID\",\"password\":\"test-password\"}") 70 - TOKEN=$(echo "$SESSION" | jq -r '.accessJwt') 71 - [ "$TOKEN" != "null" ] && [ -n "$TOKEN" ] && pass "createSession returns token" || fail "createSession" 72 - 73 - # getSession works with token 74 - curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ 75 - -H "Authorization: Bearer $TOKEN" | jq -e '.did' >/dev/null && 76 - pass "getSession with valid token" || fail "getSession" 77 - 78 - # refreshSession returns new tokens 79 - REFRESH_TOKEN=$(echo "$SESSION" | jq -r '.refreshJwt') 80 - REFRESH_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 81 - -H "Authorization: Bearer $REFRESH_TOKEN") 82 - NEW_ACCESS=$(echo "$REFRESH_RESULT" | jq -r '.accessJwt') 83 - NEW_REFRESH=$(echo "$REFRESH_RESULT" | jq -r '.refreshJwt') 84 - [ "$NEW_ACCESS" != "null" ] && [ -n "$NEW_ACCESS" ] && [ "$NEW_REFRESH" != "null" ] && [ -n "$NEW_REFRESH" ] && 85 - pass "refreshSession returns new tokens" || fail "refreshSession" 86 - 87 - # New access token from refresh works 88 - curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ 89 - -H "Authorization: Bearer $NEW_ACCESS" | jq -e '.did' >/dev/null && 90 - pass "refreshed access token works" || fail "refreshed token" 91 - 92 - # refreshSession rejects access token (wrong type) 93 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 94 - -H "Authorization: Bearer $TOKEN") 95 - [ "$STATUS" = "400" ] && pass "refreshSession rejects access token" || fail "refreshSession should reject access token" 96 - 97 - # refreshSession rejects missing auth 98 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession") 99 - [ "$STATUS" = "401" ] && pass "refreshSession rejects missing auth" || fail "refreshSession should require auth" 100 - 101 - # refreshSession rejects malformed token 102 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 103 - -H "Authorization: Bearer not-a-valid-jwt") 104 - [ "$STATUS" = "400" ] && pass "refreshSession rejects malformed token" || fail "refreshSession should reject malformed token" 105 - 106 - # Protected endpoint rejects without auth 107 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 108 - -H "Content-Type: application/json" \ 109 - -d '{"repo":"x","collection":"x","record":{}}') 110 - [ "$STATUS" = "401" ] && pass "createRecord rejects without auth" || fail "createRecord should reject" 111 - 112 - # getPreferences works (returns empty array initially) 113 - curl -sf "$BASE/xrpc/app.bsky.actor.getPreferences" \ 114 - -H "Authorization: Bearer $TOKEN" | jq -e '.preferences' >/dev/null && 115 - pass "getPreferences" || fail "getPreferences" 116 - 117 - # putPreferences works 118 - curl -sf -X POST "$BASE/xrpc/app.bsky.actor.putPreferences" \ 119 - -H "Authorization: Bearer $TOKEN" \ 120 - -H "Content-Type: application/json" \ 121 - -d '{"preferences":[{"$type":"app.bsky.actor.defs#savedFeedsPrefV2"}]}' >/dev/null && 122 - pass "putPreferences" || fail "putPreferences" 123 - 124 - # createRecord works with auth 125 - RECORD=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 126 - -H "Authorization: Bearer $TOKEN" \ 127 - -H "Content-Type: application/json" \ 128 - -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"record\":{\"text\":\"test\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}") 129 - URI=$(echo "$RECORD" | jq -r '.uri') 130 - [ "$URI" != "null" ] && [ -n "$URI" ] && pass "createRecord with auth" || fail "createRecord" 131 - 132 - # getRecord retrieves it 133 - RKEY=$(echo "$URI" | sed 's|.*/||') 134 - curl -sf "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | 135 - jq -e '.value.text' >/dev/null && pass "getRecord" || fail "getRecord" 136 - 137 - # putRecord updates the record 138 - curl -sf -X POST "$BASE/xrpc/com.atproto.repo.putRecord" \ 139 - -H "Authorization: Bearer $TOKEN" \ 140 - -H "Content-Type: application/json" \ 141 - -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\",\"record\":{\"text\":\"updated\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}" | 142 - jq -e '.uri' >/dev/null && pass "putRecord" || fail "putRecord" 143 - 144 - # listRecords shows the record 145 - curl -sf "$BASE/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.bsky.feed.post" | 146 - jq -e '.records | length > 0' >/dev/null && pass "listRecords" || fail "listRecords" 147 - 148 - # describeRepo returns repo info 149 - curl -sf "$BASE/xrpc/com.atproto.repo.describeRepo?repo=$DID" | 150 - jq -e '.did' >/dev/null && pass "describeRepo" || fail "describeRepo" 151 - 152 - # applyWrites batch operation (create then delete a record) 153 - APPLY_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 154 - -H "Authorization: Bearer $TOKEN" \ 155 - -H "Content-Type: application/json" \ 156 - -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#create\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\",\"value\":{\"text\":\"batch\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}]}") 157 - echo "$APPLY_RESULT" | jq -e '.results' >/dev/null && pass "applyWrites create" || fail "applyWrites create" 158 - 159 - # applyWrites delete 160 - curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 161 - -H "Authorization: Bearer $TOKEN" \ 162 - -H "Content-Type: application/json" \ 163 - -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#delete\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\"}]}" | 164 - jq -e '.results' >/dev/null && pass "applyWrites delete" || fail "applyWrites delete" 165 - 166 - # sync.getLatestCommit returns head 167 - curl -sf "$BASE/xrpc/com.atproto.sync.getLatestCommit?did=$DID" | 168 - jq -e '.cid' >/dev/null && pass "sync.getLatestCommit" || fail "sync.getLatestCommit" 169 - 170 - # sync.getRepoStatus returns status 171 - curl -sf "$BASE/xrpc/com.atproto.sync.getRepoStatus?did=$DID" | 172 - jq -e '.did' >/dev/null && pass "sync.getRepoStatus" || fail "sync.getRepoStatus" 173 - 174 - # sync.getRepo returns CAR file 175 - REPO_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRepo?did=$DID" | wc -c) 176 - [ "$REPO_SIZE" -gt 100 ] && pass "sync.getRepo returns CAR" || fail "sync.getRepo" 177 - 178 - # sync.getRecord returns record with proof (binary CAR data) 179 - RECORD_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRecord?did=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | wc -c) 180 - [ "$RECORD_SIZE" -gt 50 ] && pass "sync.getRecord" || fail "sync.getRecord" 181 - 182 - # sync.listRepos lists repos 183 - curl -sf "$BASE/xrpc/com.atproto.sync.listRepos" | 184 - jq -e '.repos | length > 0' >/dev/null && pass "sync.listRepos" || fail "sync.listRepos" 185 - 186 - # Error handling tests 187 - echo 188 - echo "Testing error handling..." 189 - 190 - # Invalid password rejected 191 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 192 - -H "Content-Type: application/json" \ 193 - -d "{\"identifier\":\"$DID\",\"password\":\"wrong-password\"}") 194 - [ "$STATUS" = "401" ] && pass "Invalid password rejected (401)" || fail "Invalid password should return 401" 195 - 196 - # Wrong repo rejected (can't modify another user's repo) 197 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 198 - -H "Authorization: Bearer $TOKEN" \ 199 - -H "Content-Type: application/json" \ 200 - -d '{"repo":"did:plc:z72i7hdynmk6r22z27h6tvur","collection":"app.bsky.feed.post","record":{"text":"x","createdAt":"2024-01-01T00:00:00Z"}}') 201 - [ "$STATUS" = "403" ] && pass "Wrong repo rejected (403)" || fail "Wrong repo should return 403" 202 - 203 - # Non-existent record returns 404 204 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=nonexistent") 205 - [ "$STATUS" = "400" ] || [ "$STATUS" = "404" ] && pass "Non-existent record error" || fail "Non-existent record should error" 206 - 207 - # Blob tests 208 - echo 209 - echo "Testing blob endpoints..." 210 - 211 - # Create a minimal valid PNG (1x1 transparent pixel) 212 - # PNG signature + IHDR + IDAT + IEND 213 - PNG_FILE=$(mktemp) 214 - printf '\x89PNG\r\n\x1a\n' >"$PNG_FILE" 215 - printf '\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89' >>"$PNG_FILE" 216 - printf '\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4' >>"$PNG_FILE" 217 - printf '\x00\x00\x00\x00IEND\xaeB`\x82' >>"$PNG_FILE" 218 - 219 - # uploadBlob requires auth 220 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.uploadBlob" \ 221 - -H "Content-Type: image/png" \ 222 - --data-binary @"$PNG_FILE") 223 - [ "$STATUS" = "401" ] && pass "uploadBlob rejects without auth" || fail "uploadBlob should require auth" 224 - 225 - # uploadBlob works with auth 226 - BLOB_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.uploadBlob" \ 227 - -H "Authorization: Bearer $TOKEN" \ 228 - -H "Content-Type: image/png" \ 229 - --data-binary @"$PNG_FILE") 230 - BLOB_CID=$(echo "$BLOB_RESULT" | jq -r '.blob.ref."$link"') 231 - BLOB_MIME=$(echo "$BLOB_RESULT" | jq -r '.blob.mimeType') 232 - [ "$BLOB_CID" != "null" ] && [ -n "$BLOB_CID" ] && pass "uploadBlob returns CID" || fail "uploadBlob" 233 - [ "$BLOB_MIME" = "image/png" ] && pass "uploadBlob detects PNG mime type" || fail "uploadBlob mime detection" 234 - 235 - # listBlobs shows the uploaded blob 236 - curl -sf "$BASE/xrpc/com.atproto.sync.listBlobs?did=$DID" | 237 - jq -e ".cids | index(\"$BLOB_CID\")" >/dev/null && pass "listBlobs includes uploaded blob" || fail "listBlobs" 238 - 239 - # getBlob retrieves the blob 240 - BLOB_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=$BLOB_CID" | wc -c) 241 - [ "$BLOB_SIZE" -gt 0 ] && pass "getBlob retrieves blob data" || fail "getBlob" 242 - 243 - # getBlob returns correct headers 244 - BLOB_HEADERS=$(curl -sI "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=$BLOB_CID") 245 - echo "$BLOB_HEADERS" | grep -qi "content-type: image/png" && pass "getBlob Content-Type header" || fail "getBlob Content-Type" 246 - echo "$BLOB_HEADERS" | grep -qi "x-content-type-options: nosniff" && pass "getBlob security headers" || fail "getBlob security headers" 247 - 248 - # getBlob rejects wrong DID 249 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.sync.getBlob?did=did:plc:wrongdid&cid=$BLOB_CID") 250 - [ "$STATUS" = "400" ] && pass "getBlob rejects wrong DID" || fail "getBlob should reject wrong DID" 251 - 252 - # getBlob returns 400 for invalid CID format 253 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=invalid") 254 - [ "$STATUS" = "400" ] && pass "getBlob rejects invalid CID format" || fail "getBlob should reject invalid CID" 255 - 256 - # getBlob returns 404 for non-existent blob (valid format CID - 59 chars) 257 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.sync.getBlob?did=$DID&cid=bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") 258 - [ "$STATUS" = "404" ] && pass "getBlob 404 for missing blob" || fail "getBlob should 404" 259 - 260 - # Create a record with blob reference 261 - BLOB_POST=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 262 - -H "Authorization: Bearer $TOKEN" \ 263 - -H "Content-Type: application/json" \ 264 - -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"record\":{\"text\":\"post with image\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"embed\":{\"\$type\":\"app.bsky.embed.images\",\"images\":[{\"image\":{\"\$type\":\"blob\",\"ref\":{\"\$link\":\"$BLOB_CID\"},\"mimeType\":\"image/png\",\"size\":$(wc -c <"$PNG_FILE")},\"alt\":\"test\"}]}}}") 265 - BLOB_POST_URI=$(echo "$BLOB_POST" | jq -r '.uri') 266 - BLOB_POST_RKEY=$(echo "$BLOB_POST_URI" | sed 's|.*/||') 267 - [ "$BLOB_POST_URI" != "null" ] && [ -n "$BLOB_POST_URI" ] && pass "createRecord with blob ref" || fail "createRecord with blob" 268 - 269 - # Blob still exists after record creation 270 - curl -sf "$BASE/xrpc/com.atproto.sync.listBlobs?did=$DID" | 271 - jq -e ".cids | index(\"$BLOB_CID\")" >/dev/null && pass "blob persists after record creation" || fail "blob should persist" 272 - 273 - # Delete the record with blob 274 - curl -sf -X POST "$BASE/xrpc/com.atproto.repo.deleteRecord" \ 275 - -H "Authorization: Bearer $TOKEN" \ 276 - -H "Content-Type: application/json" \ 277 - -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$BLOB_POST_RKEY\"}" >/dev/null && 278 - pass "deleteRecord with blob" || fail "deleteRecord with blob" 279 - 280 - # Blob should be cleaned up (orphaned) 281 - BLOB_COUNT=$(curl -sf "$BASE/xrpc/com.atproto.sync.listBlobs?did=$DID" | jq '.cids | length') 282 - [ "$BLOB_COUNT" = "0" ] && pass "orphaned blob cleaned up on delete" || fail "blob should be cleaned up" 283 - 284 - # Clean up temp file 285 - rm -f "$PNG_FILE" 286 - 287 - # Cleanup: delete the test record 288 - curl -sf -X POST "$BASE/xrpc/com.atproto.repo.deleteRecord" \ 289 - -H "Authorization: Bearer $TOKEN" \ 290 - -H "Content-Type: application/json" \ 291 - -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\"}" >/dev/null && 292 - pass "deleteRecord (cleanup)" || fail "deleteRecord" 293 - 294 - echo 295 - echo "All tests passed!"
+1038
test/e2e.test.js
··· 1 + /** 2 + * E2E tests for PDS - runs against local wrangler dev 3 + * Uses Node's built-in test runner and fetch 4 + */ 5 + 6 + import { describe, it, before, after } from 'node:test'; 7 + import assert from 'node:assert'; 8 + import { spawn } from 'node:child_process'; 9 + import { randomBytes } from 'node:crypto'; 10 + import { DpopClient } from './helpers/dpop.js'; 11 + 12 + const BASE = 'http://localhost:8787'; 13 + const DID = `did:plc:test${randomBytes(8).toString('hex')}`; 14 + const PASSWORD = 'test-password'; 15 + 16 + /** @type {import('node:child_process').ChildProcess|null} */ 17 + let wrangler = null; 18 + /** @type {string} */ 19 + let token = ''; 20 + /** @type {string} */ 21 + let refreshToken = ''; 22 + /** @type {string} */ 23 + let testRkey = ''; 24 + 25 + /** 26 + * Wait for server to be ready 27 + */ 28 + async function waitForServer(maxAttempts = 30) { 29 + for (let i = 0; i < maxAttempts; i++) { 30 + try { 31 + const res = await fetch(`${BASE}/`); 32 + if (res.ok) return; 33 + } catch { 34 + // Server not ready yet 35 + } 36 + await new Promise((r) => setTimeout(r, 500)); 37 + } 38 + throw new Error('Server failed to start'); 39 + } 40 + 41 + /** 42 + * Make JSON request helper 43 + */ 44 + async function jsonPost(path, body, headers = {}) { 45 + const res = await fetch(`${BASE}${path}`, { 46 + method: 'POST', 47 + headers: { 'Content-Type': 'application/json', ...headers }, 48 + body: JSON.stringify(body), 49 + }); 50 + return { status: res.status, data: res.ok ? await res.json() : null }; 51 + } 52 + 53 + /** 54 + * Make form-encoded POST 55 + */ 56 + async function formPost(path, params, headers = {}) { 57 + const res = await fetch(`${BASE}${path}`, { 58 + method: 'POST', 59 + headers: { 60 + 'Content-Type': 'application/x-www-form-urlencoded', 61 + ...headers, 62 + }, 63 + body: new URLSearchParams(params).toString(), 64 + }); 65 + const text = await res.text(); 66 + let data = null; 67 + try { 68 + data = JSON.parse(text); 69 + } catch { 70 + data = text; 71 + } 72 + return { status: res.status, data }; 73 + } 74 + 75 + describe('E2E Tests', () => { 76 + before(async () => { 77 + // Start wrangler 78 + wrangler = spawn( 79 + 'npx', 80 + ['wrangler', 'dev', '--port', '8787', '--persist-to', '.wrangler/state'], 81 + { 82 + stdio: 'pipe', 83 + cwd: process.cwd(), 84 + }, 85 + ); 86 + 87 + await waitForServer(); 88 + 89 + // Initialize PDS 90 + const privKey = randomBytes(32).toString('hex'); 91 + const res = await fetch(`${BASE}/init?did=${DID}`, { 92 + method: 'POST', 93 + headers: { 'Content-Type': 'application/json' }, 94 + body: JSON.stringify({ 95 + did: DID, 96 + privateKey: privKey, 97 + handle: 'test.local', 98 + }), 99 + }); 100 + assert.ok(res.ok, 'PDS initialization failed'); 101 + }); 102 + 103 + after(() => { 104 + if (wrangler) { 105 + wrangler.kill(); 106 + } 107 + }); 108 + 109 + describe('Server endpoints', () => { 110 + it('root returns ASCII art', async () => { 111 + const res = await fetch(`${BASE}/`); 112 + const text = await res.text(); 113 + assert.ok(text.includes('PDS'), 'Root should contain PDS'); 114 + }); 115 + 116 + it('describeServer returns DID', async () => { 117 + const res = await fetch(`${BASE}/xrpc/com.atproto.server.describeServer`); 118 + const data = await res.json(); 119 + assert.ok(data.did, 'describeServer should return did'); 120 + }); 121 + 122 + it('resolveHandle returns DID', async () => { 123 + const res = await fetch( 124 + `${BASE}/xrpc/com.atproto.identity.resolveHandle?handle=test.local`, 125 + ); 126 + const data = await res.json(); 127 + assert.ok(data.did, 'resolveHandle should return did'); 128 + }); 129 + }); 130 + 131 + describe('Authentication', () => { 132 + it('createSession returns tokens', async () => { 133 + const { status, data } = await jsonPost( 134 + '/xrpc/com.atproto.server.createSession', 135 + { 136 + identifier: DID, 137 + password: PASSWORD, 138 + }, 139 + ); 140 + assert.strictEqual(status, 200); 141 + assert.ok(data.accessJwt, 'Should return accessJwt'); 142 + assert.ok(data.refreshJwt, 'Should return refreshJwt'); 143 + token = data.accessJwt; 144 + refreshToken = data.refreshJwt; 145 + }); 146 + 147 + it('getSession with valid token', async () => { 148 + const res = await fetch(`${BASE}/xrpc/com.atproto.server.getSession`, { 149 + headers: { Authorization: `Bearer ${token}` }, 150 + }); 151 + const data = await res.json(); 152 + assert.ok(data.did, 'getSession should return did'); 153 + }); 154 + 155 + it('refreshSession returns new tokens', async () => { 156 + const res = await fetch( 157 + `${BASE}/xrpc/com.atproto.server.refreshSession`, 158 + { 159 + method: 'POST', 160 + headers: { Authorization: `Bearer ${refreshToken}` }, 161 + }, 162 + ); 163 + const data = await res.json(); 164 + assert.ok(data.accessJwt, 'Should return new accessJwt'); 165 + assert.ok(data.refreshJwt, 'Should return new refreshJwt'); 166 + token = data.accessJwt; // Use new token 167 + }); 168 + 169 + it('refreshSession rejects access token', async () => { 170 + const res = await fetch( 171 + `${BASE}/xrpc/com.atproto.server.refreshSession`, 172 + { 173 + method: 'POST', 174 + headers: { Authorization: `Bearer ${token}` }, 175 + }, 176 + ); 177 + assert.strictEqual(res.status, 400); 178 + }); 179 + 180 + it('refreshSession rejects missing auth', async () => { 181 + const res = await fetch( 182 + `${BASE}/xrpc/com.atproto.server.refreshSession`, 183 + { 184 + method: 'POST', 185 + }, 186 + ); 187 + assert.strictEqual(res.status, 401); 188 + }); 189 + 190 + it('createRecord rejects without auth', async () => { 191 + const { status } = await jsonPost('/xrpc/com.atproto.repo.createRecord', { 192 + repo: 'x', 193 + collection: 'x', 194 + record: {}, 195 + }); 196 + assert.strictEqual(status, 401); 197 + }); 198 + 199 + it('getPreferences works', async () => { 200 + const res = await fetch(`${BASE}/xrpc/app.bsky.actor.getPreferences`, { 201 + headers: { Authorization: `Bearer ${token}` }, 202 + }); 203 + const data = await res.json(); 204 + assert.ok(data.preferences, 'Should return preferences'); 205 + }); 206 + 207 + it('putPreferences works', async () => { 208 + const { status } = await jsonPost( 209 + '/xrpc/app.bsky.actor.putPreferences', 210 + { preferences: [{ $type: 'app.bsky.actor.defs#savedFeedsPrefV2' }] }, 211 + { Authorization: `Bearer ${token}` }, 212 + ); 213 + assert.strictEqual(status, 200); 214 + }); 215 + }); 216 + 217 + describe('Record operations', () => { 218 + it('createRecord with auth', async () => { 219 + const { status, data } = await jsonPost( 220 + '/xrpc/com.atproto.repo.createRecord', 221 + { 222 + repo: DID, 223 + collection: 'app.bsky.feed.post', 224 + record: { text: 'test', createdAt: new Date().toISOString() }, 225 + }, 226 + { Authorization: `Bearer ${token}` }, 227 + ); 228 + assert.strictEqual(status, 200); 229 + assert.ok(data.uri, 'Should return uri'); 230 + testRkey = data.uri.split('/').pop(); 231 + }); 232 + 233 + it('getRecord returns record', async () => { 234 + const res = await fetch( 235 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, 236 + ); 237 + const data = await res.json(); 238 + assert.ok(data.value?.text, 'Should return record value'); 239 + }); 240 + 241 + it('putRecord updates record', async () => { 242 + const { status, data } = await jsonPost( 243 + '/xrpc/com.atproto.repo.putRecord', 244 + { 245 + repo: DID, 246 + collection: 'app.bsky.feed.post', 247 + rkey: testRkey, 248 + record: { text: 'updated', createdAt: new Date().toISOString() }, 249 + }, 250 + { Authorization: `Bearer ${token}` }, 251 + ); 252 + assert.strictEqual(status, 200); 253 + assert.ok(data.uri); 254 + }); 255 + 256 + it('listRecords returns records', async () => { 257 + const res = await fetch( 258 + `${BASE}/xrpc/com.atproto.repo.listRecords?repo=${DID}&collection=app.bsky.feed.post`, 259 + ); 260 + const data = await res.json(); 261 + assert.ok(data.records?.length > 0, 'Should return records'); 262 + }); 263 + 264 + it('describeRepo returns did', async () => { 265 + const res = await fetch( 266 + `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=${DID}`, 267 + ); 268 + const data = await res.json(); 269 + assert.ok(data.did); 270 + }); 271 + 272 + it('applyWrites create', async () => { 273 + const { status, data } = await jsonPost( 274 + '/xrpc/com.atproto.repo.applyWrites', 275 + { 276 + repo: DID, 277 + writes: [ 278 + { 279 + $type: 'com.atproto.repo.applyWrites#create', 280 + collection: 'app.bsky.feed.post', 281 + rkey: 'applytest', 282 + value: { text: 'batch', createdAt: new Date().toISOString() }, 283 + }, 284 + ], 285 + }, 286 + { Authorization: `Bearer ${token}` }, 287 + ); 288 + assert.strictEqual(status, 200); 289 + assert.ok(data.results); 290 + }); 291 + 292 + it('applyWrites delete', async () => { 293 + const { status, data } = await jsonPost( 294 + '/xrpc/com.atproto.repo.applyWrites', 295 + { 296 + repo: DID, 297 + writes: [ 298 + { 299 + $type: 'com.atproto.repo.applyWrites#delete', 300 + collection: 'app.bsky.feed.post', 301 + rkey: 'applytest', 302 + }, 303 + ], 304 + }, 305 + { Authorization: `Bearer ${token}` }, 306 + ); 307 + assert.strictEqual(status, 200); 308 + assert.ok(data.results); 309 + }); 310 + }); 311 + 312 + describe('Sync endpoints', () => { 313 + it('getLatestCommit returns cid', async () => { 314 + const res = await fetch( 315 + `${BASE}/xrpc/com.atproto.sync.getLatestCommit?did=${DID}`, 316 + ); 317 + const data = await res.json(); 318 + assert.ok(data.cid); 319 + }); 320 + 321 + it('getRepoStatus returns did', async () => { 322 + const res = await fetch( 323 + `${BASE}/xrpc/com.atproto.sync.getRepoStatus?did=${DID}`, 324 + ); 325 + const data = await res.json(); 326 + assert.ok(data.did); 327 + }); 328 + 329 + it('getRepo returns CAR', async () => { 330 + const res = await fetch( 331 + `${BASE}/xrpc/com.atproto.sync.getRepo?did=${DID}`, 332 + ); 333 + const data = await res.arrayBuffer(); 334 + assert.ok(data.byteLength > 100, 'Should return CAR data'); 335 + }); 336 + 337 + it('getRecord returns record CAR', async () => { 338 + const res = await fetch( 339 + `${BASE}/xrpc/com.atproto.sync.getRecord?did=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, 340 + ); 341 + const data = await res.arrayBuffer(); 342 + assert.ok(data.byteLength > 50); 343 + }); 344 + 345 + it('listRepos returns repos', async () => { 346 + const res = await fetch(`${BASE}/xrpc/com.atproto.sync.listRepos`); 347 + const data = await res.json(); 348 + assert.ok(data.repos?.length > 0); 349 + }); 350 + }); 351 + 352 + describe('Error handling', () => { 353 + it('invalid password rejected (401)', async () => { 354 + const { status } = await jsonPost( 355 + '/xrpc/com.atproto.server.createSession', 356 + { 357 + identifier: DID, 358 + password: 'wrong-password', 359 + }, 360 + ); 361 + assert.strictEqual(status, 401); 362 + }); 363 + 364 + it('wrong repo rejected (403)', async () => { 365 + const { status } = await jsonPost( 366 + '/xrpc/com.atproto.repo.createRecord', 367 + { 368 + repo: 'did:plc:z72i7hdynmk6r22z27h6tvur', 369 + collection: 'app.bsky.feed.post', 370 + record: { text: 'x', createdAt: '2024-01-01T00:00:00Z' }, 371 + }, 372 + { Authorization: `Bearer ${token}` }, 373 + ); 374 + assert.strictEqual(status, 403); 375 + }); 376 + 377 + it('non-existent record errors', async () => { 378 + const res = await fetch( 379 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=nonexistent`, 380 + ); 381 + assert.ok([400, 404].includes(res.status)); 382 + }); 383 + }); 384 + 385 + describe('Blob endpoints', () => { 386 + /** @type {string} */ 387 + let blobCid = ''; 388 + /** @type {string} */ 389 + let blobPostRkey = ''; 390 + 391 + // Create minimal PNG 392 + const pngBytes = new Uint8Array([ 393 + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 394 + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 395 + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 396 + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 397 + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 398 + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, 399 + ]); 400 + 401 + it('uploadBlob rejects without auth', async () => { 402 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 403 + method: 'POST', 404 + headers: { 'Content-Type': 'image/png' }, 405 + body: pngBytes, 406 + }); 407 + assert.strictEqual(res.status, 401); 408 + }); 409 + 410 + it('uploadBlob returns CID', async () => { 411 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 412 + method: 'POST', 413 + headers: { 414 + 'Content-Type': 'image/png', 415 + Authorization: `Bearer ${token}`, 416 + }, 417 + body: pngBytes, 418 + }); 419 + const data = await res.json(); 420 + assert.ok(data.blob?.ref?.$link); 421 + assert.strictEqual(data.blob?.mimeType, 'image/png'); 422 + blobCid = data.blob.ref.$link; 423 + }); 424 + 425 + it('listBlobs includes uploaded blob', async () => { 426 + const res = await fetch( 427 + `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 428 + ); 429 + const data = await res.json(); 430 + assert.ok(data.cids?.includes(blobCid)); 431 + }); 432 + 433 + it('getBlob retrieves data', async () => { 434 + const res = await fetch( 435 + `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=${blobCid}`, 436 + ); 437 + assert.ok(res.ok); 438 + assert.strictEqual(res.headers.get('content-type'), 'image/png'); 439 + assert.strictEqual(res.headers.get('x-content-type-options'), 'nosniff'); 440 + }); 441 + 442 + it('getBlob rejects wrong DID', async () => { 443 + const res = await fetch( 444 + `${BASE}/xrpc/com.atproto.sync.getBlob?did=did:plc:wrongdid&cid=${blobCid}`, 445 + ); 446 + assert.strictEqual(res.status, 400); 447 + }); 448 + 449 + it('getBlob rejects invalid CID', async () => { 450 + const res = await fetch( 451 + `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=invalid`, 452 + ); 453 + assert.strictEqual(res.status, 400); 454 + }); 455 + 456 + it('getBlob 404 for missing blob', async () => { 457 + const res = await fetch( 458 + `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`, 459 + ); 460 + assert.strictEqual(res.status, 404); 461 + }); 462 + 463 + it('createRecord with blob ref', async () => { 464 + const { status, data } = await jsonPost( 465 + '/xrpc/com.atproto.repo.createRecord', 466 + { 467 + repo: DID, 468 + collection: 'app.bsky.feed.post', 469 + record: { 470 + text: 'post with image', 471 + createdAt: new Date().toISOString(), 472 + embed: { 473 + $type: 'app.bsky.embed.images', 474 + images: [ 475 + { 476 + image: { 477 + $type: 'blob', 478 + ref: { $link: blobCid }, 479 + mimeType: 'image/png', 480 + size: pngBytes.length, 481 + }, 482 + alt: 'test', 483 + }, 484 + ], 485 + }, 486 + }, 487 + }, 488 + { Authorization: `Bearer ${token}` }, 489 + ); 490 + assert.strictEqual(status, 200); 491 + blobPostRkey = data.uri.split('/').pop(); 492 + }); 493 + 494 + it('blob persists after record creation', async () => { 495 + const res = await fetch( 496 + `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 497 + ); 498 + const data = await res.json(); 499 + assert.ok(data.cids?.includes(blobCid)); 500 + }); 501 + 502 + it('deleteRecord with blob cleans up', async () => { 503 + const { status } = await jsonPost( 504 + '/xrpc/com.atproto.repo.deleteRecord', 505 + { repo: DID, collection: 'app.bsky.feed.post', rkey: blobPostRkey }, 506 + { Authorization: `Bearer ${token}` }, 507 + ); 508 + assert.strictEqual(status, 200); 509 + 510 + const res = await fetch( 511 + `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 512 + ); 513 + const data = await res.json(); 514 + assert.strictEqual( 515 + data.cids?.length, 516 + 0, 517 + 'Orphaned blob should be cleaned up', 518 + ); 519 + }); 520 + }); 521 + 522 + describe('OAuth endpoints', () => { 523 + it('AS metadata', async () => { 524 + const res = await fetch(`${BASE}/.well-known/oauth-authorization-server`); 525 + const data = await res.json(); 526 + assert.strictEqual(data.issuer, BASE); 527 + assert.strictEqual( 528 + data.authorization_endpoint, 529 + `${BASE}/oauth/authorize`, 530 + ); 531 + assert.strictEqual(data.token_endpoint, `${BASE}/oauth/token`); 532 + assert.strictEqual( 533 + data.pushed_authorization_request_endpoint, 534 + `${BASE}/oauth/par`, 535 + ); 536 + assert.strictEqual(data.revocation_endpoint, `${BASE}/oauth/revoke`); 537 + assert.strictEqual(data.jwks_uri, `${BASE}/oauth/jwks`); 538 + assert.deepStrictEqual(data.scopes_supported, ['atproto']); 539 + assert.deepStrictEqual(data.dpop_signing_alg_values_supported, ['ES256']); 540 + assert.strictEqual(data.require_pushed_authorization_requests, true); 541 + assert.strictEqual(data.client_id_metadata_document_supported, true); 542 + assert.deepStrictEqual(data.protected_resources, [BASE]); 543 + }); 544 + 545 + it('PR metadata', async () => { 546 + const res = await fetch(`${BASE}/.well-known/oauth-protected-resource`); 547 + const data = await res.json(); 548 + assert.strictEqual(data.resource, BASE); 549 + assert.deepStrictEqual(data.authorization_servers, [BASE]); 550 + }); 551 + 552 + it('JWKS endpoint', async () => { 553 + const res = await fetch(`${BASE}/oauth/jwks`); 554 + const data = await res.json(); 555 + assert.ok(data.keys?.length > 0); 556 + const key = data.keys[0]; 557 + assert.strictEqual(key.kty, 'EC'); 558 + assert.strictEqual(key.crv, 'P-256'); 559 + assert.strictEqual(key.alg, 'ES256'); 560 + assert.strictEqual(key.use, 'sig'); 561 + assert.ok(key.x && key.y, 'Should have x,y coords'); 562 + assert.ok(!key.d, 'Should not expose private key'); 563 + }); 564 + 565 + it('PAR rejects missing DPoP', async () => { 566 + const { status, data } = await formPost('/oauth/par', { 567 + client_id: 'http://localhost:3000', 568 + redirect_uri: 'http://localhost:3000/callback', 569 + response_type: 'code', 570 + scope: 'atproto', 571 + code_challenge: 'test', 572 + code_challenge_method: 'S256', 573 + }); 574 + assert.strictEqual(status, 400); 575 + assert.strictEqual(data.error, 'invalid_dpop_proof'); 576 + }); 577 + 578 + it('token rejects missing DPoP', async () => { 579 + const { status, data } = await formPost('/oauth/token', { 580 + grant_type: 'authorization_code', 581 + code: 'fake', 582 + client_id: 'http://localhost:3000', 583 + }); 584 + assert.strictEqual(status, 400); 585 + assert.strictEqual(data.error, 'invalid_dpop_proof'); 586 + }); 587 + 588 + it('revoke returns 200 for invalid token', async () => { 589 + const { status } = await formPost('/oauth/revoke', { 590 + token: 'nonexistent', 591 + client_id: 'http://localhost:3000', 592 + }); 593 + assert.strictEqual(status, 200); 594 + }); 595 + }); 596 + 597 + describe('OAuth flow with DPoP', () => { 598 + it('full PAR -> authorize -> token flow', async () => { 599 + const dpop = await DpopClient.create(); 600 + const clientId = 'http://localhost:3000'; 601 + const redirectUri = 'http://localhost:3000/callback'; 602 + const codeVerifier = randomBytes(32).toString('base64url'); 603 + 604 + // Generate code_challenge from verifier (S256) 605 + const challengeBuffer = await crypto.subtle.digest( 606 + 'SHA-256', 607 + new TextEncoder().encode(codeVerifier), 608 + ); 609 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 610 + 611 + // Step 1: PAR request 612 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 613 + const parRes = await fetch(`${BASE}/oauth/par`, { 614 + method: 'POST', 615 + headers: { 616 + 'Content-Type': 'application/x-www-form-urlencoded', 617 + DPoP: parProof, 618 + }, 619 + body: new URLSearchParams({ 620 + client_id: clientId, 621 + redirect_uri: redirectUri, 622 + response_type: 'code', 623 + scope: 'atproto', 624 + code_challenge: codeChallenge, 625 + code_challenge_method: 'S256', 626 + state: 'test-state', 627 + login_hint: DID, 628 + }).toString(), 629 + }); 630 + 631 + assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 632 + const parData = await parRes.json(); 633 + assert.ok(parData.request_uri, 'PAR should return request_uri'); 634 + assert.ok(parData.expires_in > 0, 'PAR should return expires_in'); 635 + 636 + // Step 2: Authorization (simulate user consent by POSTing to authorize) 637 + const authRes = await fetch(`${BASE}/oauth/authorize`, { 638 + method: 'POST', 639 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 640 + body: new URLSearchParams({ 641 + request_uri: parData.request_uri, 642 + client_id: clientId, 643 + password: PASSWORD, 644 + }).toString(), 645 + redirect: 'manual', 646 + }); 647 + 648 + assert.strictEqual(authRes.status, 302, 'Authorize should redirect'); 649 + const location = authRes.headers.get('location'); 650 + assert.ok(location, 'Should have Location header'); 651 + 652 + const redirectUrl = new URL(location); 653 + const authCode = redirectUrl.searchParams.get('code'); 654 + assert.ok(authCode, 'Redirect should have code'); 655 + assert.strictEqual(redirectUrl.searchParams.get('state'), 'test-state'); 656 + assert.strictEqual(redirectUrl.searchParams.get('iss'), BASE); 657 + 658 + // Step 3: Token exchange 659 + const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 660 + const tokenRes = await fetch(`${BASE}/oauth/token`, { 661 + method: 'POST', 662 + headers: { 663 + 'Content-Type': 'application/x-www-form-urlencoded', 664 + DPoP: tokenProof, 665 + }, 666 + body: new URLSearchParams({ 667 + grant_type: 'authorization_code', 668 + code: authCode, 669 + client_id: clientId, 670 + redirect_uri: redirectUri, 671 + code_verifier: codeVerifier, 672 + }).toString(), 673 + }); 674 + 675 + assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed'); 676 + const tokenData = await tokenRes.json(); 677 + assert.ok(tokenData.access_token, 'Should return access_token'); 678 + assert.ok(tokenData.refresh_token, 'Should return refresh_token'); 679 + assert.strictEqual(tokenData.token_type, 'DPoP'); 680 + assert.strictEqual(tokenData.scope, 'atproto'); 681 + assert.ok(tokenData.sub, 'Should return sub'); 682 + 683 + // Step 4: Use access token with DPoP for protected endpoint 684 + const resourceProof = await dpop.createProof( 685 + 'GET', 686 + `${BASE}/xrpc/com.atproto.server.getSession`, 687 + tokenData.access_token, 688 + ); 689 + const sessionRes = await fetch( 690 + `${BASE}/xrpc/com.atproto.server.getSession`, 691 + { 692 + headers: { 693 + Authorization: `DPoP ${tokenData.access_token}`, 694 + DPoP: resourceProof, 695 + }, 696 + }, 697 + ); 698 + 699 + assert.strictEqual( 700 + sessionRes.status, 701 + 200, 702 + 'Protected endpoint should work with DPoP token', 703 + ); 704 + const sessionData = await sessionRes.json(); 705 + assert.ok(sessionData.did, 'Should return session data'); 706 + 707 + // Step 5: Refresh token 708 + const refreshProof = await dpop.createProof( 709 + 'POST', 710 + `${BASE}/oauth/token`, 711 + ); 712 + const refreshRes = await fetch(`${BASE}/oauth/token`, { 713 + method: 'POST', 714 + headers: { 715 + 'Content-Type': 'application/x-www-form-urlencoded', 716 + DPoP: refreshProof, 717 + }, 718 + body: new URLSearchParams({ 719 + grant_type: 'refresh_token', 720 + refresh_token: tokenData.refresh_token, 721 + client_id: clientId, 722 + }).toString(), 723 + }); 724 + 725 + assert.strictEqual(refreshRes.status, 200, 'Refresh should succeed'); 726 + const refreshData = await refreshRes.json(); 727 + assert.ok(refreshData.access_token, 'Should return new access_token'); 728 + assert.ok(refreshData.refresh_token, 'Should return new refresh_token'); 729 + 730 + // Step 6: Revoke token 731 + const revokeRes = await fetch(`${BASE}/oauth/revoke`, { 732 + method: 'POST', 733 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 734 + body: new URLSearchParams({ 735 + token: refreshData.refresh_token, 736 + client_id: clientId, 737 + }).toString(), 738 + }); 739 + assert.strictEqual(revokeRes.status, 200); 740 + }); 741 + 742 + it('DPoP key mismatch rejected', async () => { 743 + const dpop1 = await DpopClient.create(); 744 + const dpop2 = await DpopClient.create(); 745 + const clientId = 'http://localhost:3000'; 746 + const redirectUri = 'http://localhost:3000/callback'; 747 + const codeVerifier = randomBytes(32).toString('base64url'); 748 + const challengeBuffer = await crypto.subtle.digest( 749 + 'SHA-256', 750 + new TextEncoder().encode(codeVerifier), 751 + ); 752 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 753 + 754 + // PAR with first key 755 + const parProof = await dpop1.createProof('POST', `${BASE}/oauth/par`); 756 + const parRes = await fetch(`${BASE}/oauth/par`, { 757 + method: 'POST', 758 + headers: { 759 + 'Content-Type': 'application/x-www-form-urlencoded', 760 + DPoP: parProof, 761 + }, 762 + body: new URLSearchParams({ 763 + client_id: clientId, 764 + redirect_uri: redirectUri, 765 + response_type: 'code', 766 + scope: 'atproto', 767 + code_challenge: codeChallenge, 768 + code_challenge_method: 'S256', 769 + login_hint: DID, 770 + }).toString(), 771 + }); 772 + const parData = await parRes.json(); 773 + 774 + // Authorize 775 + const authRes = await fetch(`${BASE}/oauth/authorize`, { 776 + method: 'POST', 777 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 778 + body: new URLSearchParams({ 779 + request_uri: parData.request_uri, 780 + client_id: clientId, 781 + password: PASSWORD, 782 + }).toString(), 783 + redirect: 'manual', 784 + }); 785 + const location = authRes.headers.get('location'); 786 + const authCode = new URL(location).searchParams.get('code'); 787 + 788 + // Token with DIFFERENT key should fail 789 + const tokenProof = await dpop2.createProof('POST', `${BASE}/oauth/token`); 790 + const tokenRes = await fetch(`${BASE}/oauth/token`, { 791 + method: 'POST', 792 + headers: { 793 + 'Content-Type': 'application/x-www-form-urlencoded', 794 + DPoP: tokenProof, 795 + }, 796 + body: new URLSearchParams({ 797 + grant_type: 'authorization_code', 798 + code: authCode, 799 + client_id: clientId, 800 + redirect_uri: redirectUri, 801 + code_verifier: codeVerifier, 802 + }).toString(), 803 + }); 804 + 805 + assert.strictEqual(tokenRes.status, 400); 806 + const tokenData = await tokenRes.json(); 807 + assert.strictEqual(tokenData.error, 'invalid_dpop_proof'); 808 + }); 809 + 810 + it('fragment response_mode returns code in fragment', async () => { 811 + const dpop = await DpopClient.create(); 812 + const clientId = 'http://localhost:3000'; 813 + const redirectUri = 'http://localhost:3000/callback'; 814 + const codeVerifier = randomBytes(32).toString('base64url'); 815 + const challengeBuffer = await crypto.subtle.digest( 816 + 'SHA-256', 817 + new TextEncoder().encode(codeVerifier), 818 + ); 819 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 820 + 821 + // PAR with response_mode=fragment 822 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 823 + const parRes = await fetch(`${BASE}/oauth/par`, { 824 + method: 'POST', 825 + headers: { 826 + 'Content-Type': 'application/x-www-form-urlencoded', 827 + DPoP: parProof, 828 + }, 829 + body: new URLSearchParams({ 830 + client_id: clientId, 831 + redirect_uri: redirectUri, 832 + response_type: 'code', 833 + response_mode: 'fragment', 834 + scope: 'atproto', 835 + code_challenge: codeChallenge, 836 + code_challenge_method: 'S256', 837 + login_hint: DID, 838 + }).toString(), 839 + }); 840 + const parData = await parRes.json(); 841 + assert.ok(parData.request_uri); 842 + 843 + // Authorize 844 + const authRes = await fetch(`${BASE}/oauth/authorize`, { 845 + method: 'POST', 846 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 847 + body: new URLSearchParams({ 848 + request_uri: parData.request_uri, 849 + client_id: clientId, 850 + password: PASSWORD, 851 + }).toString(), 852 + redirect: 'manual', 853 + }); 854 + 855 + assert.strictEqual(authRes.status, 302); 856 + const location = authRes.headers.get('location'); 857 + assert.ok(location); 858 + // For fragment mode, code should be in hash fragment 859 + assert.ok(location.includes('#'), 'Should use fragment'); 860 + const url = new URL(location); 861 + const fragment = new URLSearchParams(url.hash.slice(1)); 862 + assert.ok(fragment.get('code'), 'Code should be in fragment'); 863 + assert.ok(fragment.get('iss'), 'Issuer should be in fragment'); 864 + }); 865 + 866 + it('PKCE failure - wrong code_verifier rejected', async () => { 867 + const dpop = await DpopClient.create(); 868 + const clientId = 'http://localhost:3000'; 869 + const redirectUri = 'http://localhost:3000/callback'; 870 + const codeVerifier = randomBytes(32).toString('base64url'); 871 + const wrongVerifier = randomBytes(32).toString('base64url'); 872 + const challengeBuffer = await crypto.subtle.digest( 873 + 'SHA-256', 874 + new TextEncoder().encode(codeVerifier), 875 + ); 876 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 877 + 878 + // PAR 879 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 880 + const parRes = await fetch(`${BASE}/oauth/par`, { 881 + method: 'POST', 882 + headers: { 883 + 'Content-Type': 'application/x-www-form-urlencoded', 884 + DPoP: parProof, 885 + }, 886 + body: new URLSearchParams({ 887 + client_id: clientId, 888 + redirect_uri: redirectUri, 889 + response_type: 'code', 890 + scope: 'atproto', 891 + code_challenge: codeChallenge, 892 + code_challenge_method: 'S256', 893 + login_hint: DID, 894 + }).toString(), 895 + }); 896 + const parData = await parRes.json(); 897 + 898 + // Authorize 899 + const authRes = await fetch(`${BASE}/oauth/authorize`, { 900 + method: 'POST', 901 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 902 + body: new URLSearchParams({ 903 + request_uri: parData.request_uri, 904 + client_id: clientId, 905 + password: PASSWORD, 906 + }).toString(), 907 + redirect: 'manual', 908 + }); 909 + const location = authRes.headers.get('location'); 910 + const authCode = new URL(location).searchParams.get('code'); 911 + 912 + // Token with WRONG code_verifier should fail 913 + const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 914 + const tokenRes = await fetch(`${BASE}/oauth/token`, { 915 + method: 'POST', 916 + headers: { 917 + 'Content-Type': 'application/x-www-form-urlencoded', 918 + DPoP: tokenProof, 919 + }, 920 + body: new URLSearchParams({ 921 + grant_type: 'authorization_code', 922 + code: authCode, 923 + client_id: clientId, 924 + redirect_uri: redirectUri, 925 + code_verifier: wrongVerifier, 926 + }).toString(), 927 + }); 928 + 929 + assert.strictEqual(tokenRes.status, 400); 930 + const tokenData = await tokenRes.json(); 931 + assert.strictEqual(tokenData.error, 'invalid_grant'); 932 + assert.ok(tokenData.message?.includes('code_verifier')); 933 + }); 934 + 935 + it('redirect_uri mismatch rejected', async () => { 936 + const dpop = await DpopClient.create(); 937 + const clientId = 'http://localhost:3000'; 938 + const codeVerifier = randomBytes(32).toString('base64url'); 939 + const challengeBuffer = await crypto.subtle.digest( 940 + 'SHA-256', 941 + new TextEncoder().encode(codeVerifier), 942 + ); 943 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 944 + 945 + // PAR with unregistered redirect_uri 946 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 947 + const parRes = await fetch(`${BASE}/oauth/par`, { 948 + method: 'POST', 949 + headers: { 950 + 'Content-Type': 'application/x-www-form-urlencoded', 951 + DPoP: parProof, 952 + }, 953 + body: new URLSearchParams({ 954 + client_id: clientId, 955 + redirect_uri: 'http://attacker.com/callback', 956 + response_type: 'code', 957 + scope: 'atproto', 958 + code_challenge: codeChallenge, 959 + code_challenge_method: 'S256', 960 + login_hint: DID, 961 + }).toString(), 962 + }); 963 + 964 + assert.strictEqual(parRes.status, 400); 965 + const parData = await parRes.json(); 966 + assert.strictEqual(parData.error, 'invalid_request'); 967 + assert.ok(parData.message?.includes('redirect_uri')); 968 + }); 969 + 970 + it('DPoP jti replay rejected', async () => { 971 + const dpop = await DpopClient.create(); 972 + const clientId = 'http://localhost:3000'; 973 + const redirectUri = 'http://localhost:3000/callback'; 974 + const codeVerifier = randomBytes(32).toString('base64url'); 975 + const challengeBuffer = await crypto.subtle.digest( 976 + 'SHA-256', 977 + new TextEncoder().encode(codeVerifier), 978 + ); 979 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 980 + 981 + // Create a single DPoP proof 982 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 983 + 984 + // First request should succeed 985 + const parRes1 = await fetch(`${BASE}/oauth/par`, { 986 + method: 'POST', 987 + headers: { 988 + 'Content-Type': 'application/x-www-form-urlencoded', 989 + DPoP: parProof, 990 + }, 991 + body: new URLSearchParams({ 992 + client_id: clientId, 993 + redirect_uri: redirectUri, 994 + response_type: 'code', 995 + scope: 'atproto', 996 + code_challenge: codeChallenge, 997 + code_challenge_method: 'S256', 998 + login_hint: DID, 999 + }).toString(), 1000 + }); 1001 + assert.strictEqual(parRes1.status, 200); 1002 + 1003 + // Second request with SAME proof should be rejected 1004 + const parRes2 = await fetch(`${BASE}/oauth/par`, { 1005 + method: 'POST', 1006 + headers: { 1007 + 'Content-Type': 'application/x-www-form-urlencoded', 1008 + DPoP: parProof, 1009 + }, 1010 + body: new URLSearchParams({ 1011 + client_id: clientId, 1012 + redirect_uri: redirectUri, 1013 + response_type: 'code', 1014 + scope: 'atproto', 1015 + code_challenge: codeChallenge, 1016 + code_challenge_method: 'S256', 1017 + login_hint: DID, 1018 + }).toString(), 1019 + }); 1020 + 1021 + assert.strictEqual(parRes2.status, 400); 1022 + const data = await parRes2.json(); 1023 + assert.strictEqual(data.error, 'invalid_dpop_proof'); 1024 + assert.ok(data.message?.includes('replay')); 1025 + }); 1026 + }); 1027 + 1028 + describe('Cleanup', () => { 1029 + it('deleteRecord (cleanup)', async () => { 1030 + const { status } = await jsonPost( 1031 + '/xrpc/com.atproto.repo.deleteRecord', 1032 + { repo: DID, collection: 'app.bsky.feed.post', rkey: testRkey }, 1033 + { Authorization: `Bearer ${token}` }, 1034 + ); 1035 + assert.strictEqual(status, 200); 1036 + }); 1037 + }); 1038 + });
+114
test/helpers/dpop.js
··· 1 + /** 2 + * DPoP proof generation for e2e tests 3 + */ 4 + 5 + import { base64UrlEncode, computeJwkThumbprint } from '../../src/pds.js'; 6 + 7 + /** 8 + * Generate an ES256 key pair for DPoP 9 + * @returns {Promise<{privateKey: CryptoKey, publicKey: CryptoKey, jwk: object}>} 10 + */ 11 + export async function generateKeyPair() { 12 + const keyPair = await crypto.subtle.generateKey( 13 + { name: 'ECDSA', namedCurve: 'P-256' }, 14 + true, 15 + ['sign', 'verify'], 16 + ); 17 + 18 + const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); 19 + const publicJwk = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; 20 + 21 + return { 22 + privateKey: keyPair.privateKey, 23 + publicKey: keyPair.publicKey, 24 + jwk: publicJwk, 25 + }; 26 + } 27 + 28 + /** 29 + * Create a DPoP proof JWT 30 + * @param {object} params 31 + * @param {CryptoKey} params.privateKey 32 + * @param {object} params.jwk 33 + * @param {string} params.method 34 + * @param {string} params.url 35 + * @param {string} [params.accessToken] 36 + * @returns {Promise<string>} 37 + */ 38 + export async function createDpopProof({ 39 + privateKey, 40 + jwk, 41 + method, 42 + url, 43 + accessToken, 44 + }) { 45 + const header = { typ: 'dpop+jwt', alg: 'ES256', jwk }; 46 + const payload = { 47 + jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))), 48 + htm: method, 49 + htu: url, 50 + iat: Math.floor(Date.now() / 1000), 51 + }; 52 + 53 + if (accessToken) { 54 + const hash = await crypto.subtle.digest( 55 + 'SHA-256', 56 + new TextEncoder().encode(accessToken), 57 + ); 58 + payload.ath = base64UrlEncode(new Uint8Array(hash)); 59 + } 60 + 61 + const headerB64 = base64UrlEncode( 62 + new TextEncoder().encode(JSON.stringify(header)), 63 + ); 64 + const payloadB64 = base64UrlEncode( 65 + new TextEncoder().encode(JSON.stringify(payload)), 66 + ); 67 + const signingInput = `${headerB64}.${payloadB64}`; 68 + 69 + const signature = await crypto.subtle.sign( 70 + { name: 'ECDSA', hash: 'SHA-256' }, 71 + privateKey, 72 + new TextEncoder().encode(signingInput), 73 + ); 74 + 75 + return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`; 76 + } 77 + 78 + /** 79 + * DPoP client helper 80 + */ 81 + export class DpopClient { 82 + #privateKey; 83 + #jwk; 84 + #jkt = null; 85 + 86 + constructor(privateKey, jwk) { 87 + this.#privateKey = privateKey; 88 + this.#jwk = jwk; 89 + } 90 + 91 + static async create() { 92 + const { privateKey, jwk } = await generateKeyPair(); 93 + return new DpopClient(privateKey, jwk); 94 + } 95 + 96 + async getJkt() { 97 + if (!this.#jkt) this.#jkt = await computeJwkThumbprint(this.#jwk); 98 + return this.#jkt; 99 + } 100 + 101 + getJwk() { 102 + return this.#jwk; 103 + } 104 + 105 + async createProof(method, url, accessToken) { 106 + return createDpopProof({ 107 + privateKey: this.#privateKey, 108 + jwk: this.#jwk, 109 + method, 110 + url, 111 + accessToken, 112 + }); 113 + } 114 + }
+75
test/pds.test.js
··· 10 10 cborDecode, 11 11 cborEncode, 12 12 cidToString, 13 + computeJwkThumbprint, 13 14 createAccessJwt, 14 15 createCid, 15 16 createBlobCid, ··· 18 19 findBlobRefs, 19 20 generateKeyPair, 20 21 getKeyDepth, 22 + getLoopbackClientMetadata, 21 23 hexToBytes, 22 24 importPrivateKey, 25 + isLoopbackClient, 23 26 sign, 24 27 sniffMimeType, 28 + validateClientMetadata, 25 29 varint, 26 30 verifyAccessJwt, 27 31 verifyRefreshJwt, ··· 752 756 assert.deepStrictEqual(findBlobRefs(42), []); 753 757 }); 754 758 }); 759 + 760 + describe('JWK Thumbprint', () => { 761 + test('computes deterministic thumbprint for EC key', async () => { 762 + // Test vector: known JWK and its expected thumbprint 763 + const jwk = { 764 + kty: 'EC', 765 + crv: 'P-256', 766 + x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', 767 + y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ', 768 + }; 769 + 770 + const jkt1 = await computeJwkThumbprint(jwk); 771 + const jkt2 = await computeJwkThumbprint(jwk); 772 + 773 + // Thumbprint must be deterministic 774 + assert.strictEqual(jkt1, jkt2); 775 + // Must be base64url-encoded SHA-256 (43 chars) 776 + assert.strictEqual(jkt1.length, 43); 777 + // Must only contain base64url characters 778 + assert.match(jkt1, /^[A-Za-z0-9_-]+$/); 779 + }); 780 + 781 + test('produces different thumbprints for different keys', async () => { 782 + const jwk1 = { 783 + kty: 'EC', 784 + crv: 'P-256', 785 + x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', 786 + y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ', 787 + }; 788 + const jwk2 = { 789 + kty: 'EC', 790 + crv: 'P-256', 791 + x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', 792 + y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', 793 + }; 794 + 795 + const jkt1 = await computeJwkThumbprint(jwk1); 796 + const jkt2 = await computeJwkThumbprint(jwk2); 797 + 798 + assert.notStrictEqual(jkt1, jkt2); 799 + }); 800 + }); 801 + 802 + describe('Client Metadata', () => { 803 + test('isLoopbackClient detects localhost', () => { 804 + assert.strictEqual(isLoopbackClient('http://localhost:8080'), true); 805 + assert.strictEqual(isLoopbackClient('http://127.0.0.1:3000'), true); 806 + assert.strictEqual(isLoopbackClient('https://example.com'), false); 807 + }); 808 + 809 + test('getLoopbackClientMetadata returns permissive defaults', () => { 810 + const metadata = getLoopbackClientMetadata('http://localhost:8080'); 811 + assert.strictEqual(metadata.client_id, 'http://localhost:8080'); 812 + assert.ok(metadata.grant_types.includes('authorization_code')); 813 + assert.strictEqual(metadata.dpop_bound_access_tokens, true); 814 + }); 815 + 816 + test('validateClientMetadata rejects mismatched client_id', () => { 817 + const metadata = { 818 + client_id: 'https://other.com/metadata.json', 819 + redirect_uris: ['https://example.com/callback'], 820 + grant_types: ['authorization_code'], 821 + response_types: ['code'], 822 + }; 823 + assert.throws( 824 + () => 825 + validateClientMetadata(metadata, 'https://example.com/metadata.json'), 826 + /client_id mismatch/, 827 + ); 828 + }); 829 + });