this repo has no description
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 20In `src/pds.js`, add to the `initializeDatabase` function after existing table creation: 21 22```javascript 23// OAuth authorization requests (from PAR) 24await 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 40await 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 46await 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 61await sql` 62 CREATE INDEX IF NOT EXISTS idx_tokens_did ON tokens(did) 63`; 64``` 65 66**Step 2: Commit** 67 68```bash 69git add src/pds.js 70git 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 83Add to `test/pds.test.js` imports and test: 84 85```javascript 86import { 87 // ... existing imports ... 88 computeJwkThumbprint, 89} from '../src/pds.js'; 90 91describe('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 126Add 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 */ 136export 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 155npm test 156git add src/pds.js test/pds.test.js 157git 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 171import { 172 // ... existing imports ... 173 isLoopbackClient, 174 getLoopbackClientMetadata, 175 validateClientMetadata, 176} from '../src/pds.js'; 177 178describe('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 */ 216export 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 */ 243export 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 */ 262export 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 }>} */ 274const 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 */ 283async 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 306npm test 307git add src/pds.js test/pds.test.js 308git 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 */ 340async 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 */ 398function 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 423git add src/pds.js 424git 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 438if (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 459if (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 470if (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 */ 488async 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 505git add src/pds.js 506git 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 519if (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 */ 532async 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 598git add src/pds.js 599git 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 612if (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 */ 625async 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 */ 657function 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} 665button{padding:10px 20px;margin:5px;cursor:pointer} 666.approve{background:#06c;color:#fff;border:none} 667input{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 685if (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 */ 698async 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 752git add src/pds.js 753git 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 766if (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 */ 779async 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 */ 813async 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 */ 878async 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 901git add src/pds.js 902git 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 */ 924async 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 967git add src/pds.js 968git 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 */ 996async 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 */ 1020async 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 1063git add src/pds.js 1064git 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 1077if (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 */ 1090async 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 1111git add src/pds.js 1112git 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 1124Add after the existing tests: 1125 1126```bash 1127# OAuth tests 1128echo 1129echo "Testing OAuth endpoints..." 1130 1131# Test OAuth Authorization Server Metadata 1132echo "Testing OAuth AS metadata..." 1133AS_METADATA=$(curl -sf "$BASE/.well-known/oauth-authorization-server") 1134echo "$AS_METADATA" | jq -e '.issuer == "'"$BASE"'"' >/dev/null && 1135 pass "AS metadata: issuer matches base URL" || fail "AS metadata: issuer mismatch" 1136echo "$AS_METADATA" | jq -e '.authorization_endpoint == "'"$BASE"'/oauth/authorize"' >/dev/null && 1137 pass "AS metadata: authorization_endpoint" || fail "AS metadata: authorization_endpoint" 1138echo "$AS_METADATA" | jq -e '.token_endpoint == "'"$BASE"'/oauth/token"' >/dev/null && 1139 pass "AS metadata: token_endpoint" || fail "AS metadata: token_endpoint" 1140echo "$AS_METADATA" | jq -e '.pushed_authorization_request_endpoint == "'"$BASE"'/oauth/par"' >/dev/null && 1141 pass "AS metadata: PAR endpoint" || fail "AS metadata: PAR endpoint" 1142echo "$AS_METADATA" | jq -e '.revocation_endpoint == "'"$BASE"'/oauth/revoke"' >/dev/null && 1143 pass "AS metadata: revocation_endpoint" || fail "AS metadata: revocation_endpoint" 1144echo "$AS_METADATA" | jq -e '.jwks_uri == "'"$BASE"'/oauth/jwks"' >/dev/null && 1145 pass "AS metadata: jwks_uri" || fail "AS metadata: jwks_uri" 1146echo "$AS_METADATA" | jq -e '.scopes_supported | contains(["atproto"])' >/dev/null && 1147 pass "AS metadata: scopes_supported includes atproto" || fail "AS metadata: scopes_supported" 1148echo "$AS_METADATA" | jq -e '.response_types_supported | contains(["code"])' >/dev/null && 1149 pass "AS metadata: response_types_supported" || fail "AS metadata: response_types_supported" 1150echo "$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" 1152echo "$AS_METADATA" | jq -e '.code_challenge_methods_supported | contains(["S256"])' >/dev/null && 1153 pass "AS metadata: PKCE S256 supported" || fail "AS metadata: PKCE S256" 1154echo "$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" 1156echo "$AS_METADATA" | jq -e '.require_pushed_authorization_requests == true' >/dev/null && 1157 pass "AS metadata: PAR required" || fail "AS metadata: PAR required" 1158echo "$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 1162echo "Testing OAuth PR metadata..." 1163PR_METADATA=$(curl -sf "$BASE/.well-known/oauth-protected-resource") 1164echo "$PR_METADATA" | jq -e '.resource == "'"$BASE"'"' >/dev/null && 1165 pass "PR metadata: resource matches base URL" || fail "PR metadata: resource mismatch" 1166echo "$PR_METADATA" | jq -e '.authorization_servers | contains(["'"$BASE"'"])' >/dev/null && 1167 pass "PR metadata: authorization_servers" || fail "PR metadata: authorization_servers" 1168echo "$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 1172echo "Testing JWKS endpoint..." 1173JWKS=$(curl -sf "$BASE/oauth/jwks") 1174echo "$JWKS" | jq -e '.keys | length > 0' >/dev/null && 1175 pass "JWKS: has at least one key" || fail "JWKS: no keys" 1176echo "$JWKS" | jq -e '.keys[0].kty == "EC"' >/dev/null && 1177 pass "JWKS: key is EC type" || fail "JWKS: key type" 1178echo "$JWKS" | jq -e '.keys[0].crv == "P-256"' >/dev/null && 1179 pass "JWKS: key uses P-256 curve" || fail "JWKS: curve" 1180echo "$JWKS" | jq -e '.keys[0].alg == "ES256"' >/dev/null && 1181 pass "JWKS: key algorithm is ES256" || fail "JWKS: algorithm" 1182echo "$JWKS" | jq -e '.keys[0].use == "sig"' >/dev/null && 1183 pass "JWKS: key use is sig" || fail "JWKS: key use" 1184echo "$JWKS" | jq -e '.keys[0].kid == "pds-oauth-key"' >/dev/null && 1185 pass "JWKS: kid is pds-oauth-key" || fail "JWKS: kid" 1186echo "$JWKS" | jq -e '.keys[0] | has("x") and has("y")' >/dev/null && 1187 pass "JWKS: has x and y coordinates" || fail "JWKS: coordinates" 1188echo "$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 1192echo "Testing PAR error handling..." 1193PAR_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") 1196PAR_BODY=$(echo "$PAR_NO_DPOP" | head -n -1) 1197PAR_STATUS=$(echo "$PAR_NO_DPOP" | tail -n 1) 1198[ "$PAR_STATUS" = "400" ] && pass "PAR: rejects missing DPoP (400)" || fail "PAR: should reject missing DPoP" 1199echo "$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 1203echo "Testing token endpoint error handling..." 1204TOKEN_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") 1207TOKEN_BODY=$(echo "$TOKEN_NO_DPOP" | head -n -1) 1208TOKEN_STATUS=$(echo "$TOKEN_NO_DPOP" | tail -n 1) 1209[ "$TOKEN_STATUS" = "400" ] && pass "Token: rejects missing DPoP (400)" || fail "Token: should reject missing DPoP" 1210echo "$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) 1214echo "Testing revoke endpoint..." 1215REVOKE_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 1220echo 1221echo "All OAuth endpoint tests passed!" 1222``` 1223 1224**Step 2: Commit** 1225 1226```bash 1227git add test/e2e.sh 1228git 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 1241npm run typecheck 1242``` 1243 1244Expect: No type errors. If there are errors, fix them before continuing. 1245 1246**Step 2: Run unit tests** 1247 1248```bash 1249npm test 1250``` 1251 1252Expect: All tests pass. 1253 1254**Step 3: Run e2e tests** 1255 1256Start wrangler dev in one terminal, then run: 1257 1258```bash 1259./test/e2e.sh 1260``` 1261 1262Expect: All tests pass. 1263 1264**Step 4: Final commit (if any fixes were needed)** 1265 1266```bash 1267git add src/pds.js 1268git commit -m "fix(oauth): address typecheck errors" 1269``` 1270 1271--- 1272 1273## Summary 1274 1275This 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 1283All implemented with zero external dependencies using Web Crypto APIs.