this repo has no description
1# Direct Authorization Support Implementation Plan 2 3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 5**Goal:** Support direct OAuth authorization requests (without PAR) to match the official AT Protocol PDS behavior. 6 7**Architecture:** When `/oauth/authorize` receives direct parameters instead of a `request_uri`, create an authorization request record on-the-fly (same as PAR does internally), then render the consent page. The token endpoint will bind DPoP at exchange time for direct auth flows. 8 9**Tech Stack:** JavaScript, Cloudflare Workers, SQLite 10 11--- 12 13## Task 1: Add Tests for Direct Authorization 14 15**Files:** 16- Modify: `test/e2e.test.js` 17 18**Step 1: Write failing test for direct authorization GET** 19 20Add this test in the `OAuth endpoints` describe block (after existing OAuth tests around line 1452): 21 22```javascript 23 it('supports direct authorization without PAR', async () => { 24 const clientId = `http://localhost:${mockClientPort}/client-metadata.json`; 25 const redirectUri = `http://localhost:${mockClientPort}/callback`; 26 const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; 27 const codeChallenge = await generateCodeChallenge(codeVerifier); 28 const state = 'test-direct-auth-state'; 29 30 // Step 1: GET authorize with direct parameters (no PAR) 31 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 32 authorizeUrl.searchParams.set('client_id', clientId); 33 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 34 authorizeUrl.searchParams.set('response_type', 'code'); 35 authorizeUrl.searchParams.set('scope', 'atproto'); 36 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 37 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 38 authorizeUrl.searchParams.set('state', state); 39 authorizeUrl.searchParams.set('login_hint', DID); 40 41 const getRes = await fetch(authorizeUrl.toString()); 42 assert.strictEqual(getRes.status, 200, 'Direct authorize GET should succeed'); 43 44 const html = await getRes.text(); 45 assert.ok(html.includes('Authorize'), 'Should show consent page'); 46 assert.ok(html.includes('request_uri'), 'Should include request_uri in form'); 47 }); 48``` 49 50**Step 2: Run test to verify it fails** 51 52Run: `npm test -- --grep "supports direct authorization"` 53 54Expected: FAIL with "Direct authorize GET should succeed" - status will be 400 "Missing parameters" 55 56**Step 3: Add test for full direct auth flow** 57 58Add after the previous test: 59 60```javascript 61 it('completes full direct authorization flow', async () => { 62 const clientId = `http://localhost:${mockClientPort}/client-metadata.json`; 63 const redirectUri = `http://localhost:${mockClientPort}/callback`; 64 const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; 65 const codeChallenge = await generateCodeChallenge(codeVerifier); 66 const state = 'test-direct-auth-state'; 67 68 // Step 1: GET authorize with direct parameters 69 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 70 authorizeUrl.searchParams.set('client_id', clientId); 71 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 72 authorizeUrl.searchParams.set('response_type', 'code'); 73 authorizeUrl.searchParams.set('scope', 'atproto'); 74 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 75 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 76 authorizeUrl.searchParams.set('state', state); 77 authorizeUrl.searchParams.set('login_hint', DID); 78 79 const getRes = await fetch(authorizeUrl.toString()); 80 assert.strictEqual(getRes.status, 200); 81 const html = await getRes.text(); 82 83 // Extract request_uri from the form 84 const requestUriMatch = html.match(/name="request_uri" value="([^"]+)"/); 85 assert.ok(requestUriMatch, 'Should have request_uri in form'); 86 const requestUri = requestUriMatch[1]; 87 88 // Step 2: POST to authorize (user approval) 89 const authRes = await fetch(`${BASE}/oauth/authorize`, { 90 method: 'POST', 91 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 92 body: new URLSearchParams({ 93 request_uri: requestUri, 94 client_id: clientId, 95 password: PASSWORD, 96 }).toString(), 97 redirect: 'manual', 98 }); 99 100 assert.strictEqual(authRes.status, 302, 'Should redirect after approval'); 101 const location = authRes.headers.get('location'); 102 assert.ok(location, 'Should have Location header'); 103 const locationUrl = new URL(location); 104 const code = locationUrl.searchParams.get('code'); 105 assert.ok(code, 'Should have authorization code'); 106 assert.strictEqual(locationUrl.searchParams.get('state'), state); 107 108 // Step 3: Exchange code for tokens 109 const { privateKey: dpopPrivateKey, publicJwk: dpopPublicJwk } = 110 await generateDpopKeyPair(); 111 const dpopProof = await createDpopProof( 112 dpopPrivateKey, 113 dpopPublicJwk, 114 'POST', 115 `${BASE}/oauth/token`, 116 ); 117 118 const tokenRes = await fetch(`${BASE}/oauth/token`, { 119 method: 'POST', 120 headers: { 121 'Content-Type': 'application/x-www-form-urlencoded', 122 DPoP: dpopProof, 123 }, 124 body: new URLSearchParams({ 125 grant_type: 'authorization_code', 126 code, 127 redirect_uri: redirectUri, 128 client_id: clientId, 129 code_verifier: codeVerifier, 130 }).toString(), 131 }); 132 133 assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed'); 134 const tokenData = await tokenRes.json(); 135 assert.ok(tokenData.access_token, 'Should have access_token'); 136 assert.strictEqual(tokenData.token_type, 'DPoP'); 137 }); 138``` 139 140**Step 4: Run tests to verify they fail** 141 142Run: `npm test -- --grep "direct authorization"` 143 144Expected: Both tests FAIL 145 146**Step 5: Commit test file** 147 148```bash 149git add test/e2e.test.js 150git commit -m "test: add failing tests for direct OAuth authorization flow" 151``` 152 153--- 154 155## Task 2: Extract Shared Validation Logic 156 157**Files:** 158- Modify: `src/pds.js:3737-3845` (handleOAuthPar method) 159 160**Step 1: Create validateAuthorizationParameters helper** 161 162Add this new method to the PersonalDataServer class, before `handleOAuthPar` (around line 3730): 163 164```javascript 165 /** 166 * Validate OAuth authorization request parameters. 167 * Shared between PAR and direct authorization flows. 168 * @param {Object} params - The authorization parameters 169 * @param {string} params.clientId - The client_id 170 * @param {string} params.redirectUri - The redirect_uri 171 * @param {string} params.responseType - The response_type 172 * @param {string} [params.responseMode] - The response_mode 173 * @param {string} [params.scope] - The scope 174 * @param {string} [params.state] - The state 175 * @param {string} params.codeChallenge - The code_challenge 176 * @param {string} params.codeChallengeMethod - The code_challenge_method 177 * @param {string} [params.loginHint] - The login_hint 178 * @returns {Promise<{error: Response} | {clientMetadata: ClientMetadata}>} 179 */ 180 async validateAuthorizationParameters({ 181 clientId, 182 redirectUri, 183 responseType, 184 codeChallenge, 185 codeChallengeMethod, 186 }) { 187 if (!clientId) { 188 return { error: errorResponse('invalid_request', 'client_id required', 400) }; 189 } 190 if (!redirectUri) { 191 return { error: errorResponse('invalid_request', 'redirect_uri required', 400) }; 192 } 193 if (responseType !== 'code') { 194 return { 195 error: errorResponse( 196 'unsupported_response_type', 197 'response_type must be code', 198 400, 199 ), 200 }; 201 } 202 if (!codeChallenge || codeChallengeMethod !== 'S256') { 203 return { error: errorResponse('invalid_request', 'PKCE with S256 required', 400) }; 204 } 205 206 let clientMetadata; 207 try { 208 clientMetadata = await getClientMetadata(clientId); 209 } catch (err) { 210 return { error: errorResponse('invalid_client', err.message, 400) }; 211 } 212 213 // Validate redirect_uri against registered URIs 214 const isLoopback = 215 clientId.startsWith('http://localhost') || 216 clientId.startsWith('http://127.0.0.1'); 217 const redirectUriValid = clientMetadata.redirect_uris.some((uri) => { 218 if (isLoopback) { 219 try { 220 const registered = new URL(uri); 221 const requested = new URL(redirectUri); 222 return registered.origin === requested.origin; 223 } catch { 224 return false; 225 } 226 } 227 return uri === redirectUri; 228 }); 229 if (!redirectUriValid) { 230 return { 231 error: errorResponse( 232 'invalid_request', 233 'redirect_uri not registered for this client', 234 400, 235 ), 236 }; 237 } 238 239 return { clientMetadata }; 240 } 241``` 242 243**Step 2: Run existing tests to verify nothing broke** 244 245Run: `npm test` 246 247Expected: All existing tests PASS (new method not called yet) 248 249**Step 3: Commit** 250 251```bash 252git add src/pds.js 253git commit -m "refactor: extract validateAuthorizationParameters helper" 254``` 255 256--- 257 258## Task 3: Refactor handleOAuthPar to Use Shared Validation 259 260**Files:** 261- Modify: `src/pds.js:3737-3845` (handleOAuthPar method) 262 263**Step 1: Update handleOAuthPar to use the new helper** 264 265Replace the validation section in `handleOAuthPar` (lines ~3760-3815) with: 266 267```javascript 268 async handleOAuthPar(request, url) { 269 // Opportunistically clean up expired authorization requests 270 this.cleanupExpiredAuthorizationRequests(); 271 272 const issuer = `${url.protocol}//${url.host}`; 273 274 const dpopResult = await this.validateRequiredDpop( 275 request, 276 'POST', 277 `${issuer}/oauth/par`, 278 ); 279 if ('error' in dpopResult) return dpopResult.error; 280 const { dpop } = dpopResult; 281 282 // Parse body - support both JSON and form-encoded 283 /** @type {Record<string, string|undefined>} */ 284 let data; 285 try { 286 data = await parseRequestBody(request); 287 } catch { 288 return errorResponse('invalid_request', 'Invalid JSON body', 400); 289 } 290 291 const clientId = data.client_id; 292 const redirectUri = data.redirect_uri; 293 const responseType = data.response_type; 294 const responseMode = data.response_mode; 295 const scope = data.scope; 296 const state = data.state; 297 const codeChallenge = data.code_challenge; 298 const codeChallengeMethod = data.code_challenge_method; 299 const loginHint = data.login_hint; 300 301 // Use shared validation 302 const validationResult = await this.validateAuthorizationParameters({ 303 clientId, 304 redirectUri, 305 responseType, 306 codeChallenge, 307 codeChallengeMethod, 308 }); 309 if ('error' in validationResult) return validationResult.error; 310 const { clientMetadata } = validationResult; 311 312 const requestId = crypto.randomUUID(); 313 const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; 314 const expiresIn = 600; 315 const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); 316 317 this.sql.exec( 318 `INSERT INTO authorization_requests ( 319 id, client_id, client_metadata, parameters, 320 code_challenge, code_challenge_method, dpop_jkt, 321 expires_at, created_at 322 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 323 requestId, 324 clientId, 325 JSON.stringify(clientMetadata), 326 JSON.stringify({ 327 redirect_uri: redirectUri, 328 scope, 329 state, 330 response_mode: responseMode, 331 login_hint: loginHint, 332 }), 333 codeChallenge, 334 codeChallengeMethod, 335 dpop.jkt, 336 expiresAt, 337 new Date().toISOString(), 338 ); 339 340 return Response.json({ request_uri: requestUri, expires_in: expiresIn }); 341 } 342``` 343 344**Step 2: Run all OAuth tests to verify PAR still works** 345 346Run: `npm test -- --grep OAuth` 347 348Expected: All existing OAuth tests PASS 349 350**Step 3: Commit** 351 352```bash 353git add src/pds.js 354git commit -m "refactor: use validateAuthorizationParameters in handleOAuthPar" 355``` 356 357--- 358 359## Task 4: Implement Direct Authorization in handleOAuthAuthorizeGet 360 361**Files:** 362- Modify: `src/pds.js:3869-3911` (handleOAuthAuthorizeGet method) 363 364**Step 1: Update handleOAuthAuthorizeGet to handle direct parameters** 365 366Replace the entire `handleOAuthAuthorizeGet` method: 367 368```javascript 369 /** 370 * Handle GET /oauth/authorize - displays the consent UI. 371 * Supports both PAR (request_uri) and direct authorization parameters. 372 * @param {URL} url - Parsed request URL 373 * @returns {Promise<Response>} HTML consent page 374 */ 375 async handleOAuthAuthorizeGet(url) { 376 // Opportunistically clean up expired authorization requests 377 this.cleanupExpiredAuthorizationRequests(); 378 379 const requestUri = url.searchParams.get('request_uri'); 380 const clientId = url.searchParams.get('client_id'); 381 382 // If request_uri is present, use PAR flow 383 if (requestUri) { 384 if (!clientId) { 385 return new Response('Missing client_id parameter', { status: 400 }); 386 } 387 388 const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 389 if (!match) return new Response('Invalid request_uri', { status: 400 }); 390 391 const rows = this.sql 392 .exec( 393 `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 394 match[1], 395 clientId, 396 ) 397 .toArray(); 398 const authRequest = rows[0]; 399 400 if (!authRequest) return new Response('Request not found', { status: 400 }); 401 if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date()) 402 return new Response('Request expired', { status: 400 }); 403 if (authRequest.code) 404 return new Response('Request already used', { status: 400 }); 405 406 const clientMetadata = JSON.parse( 407 /** @type {string} */ (authRequest.client_metadata), 408 ); 409 const parameters = JSON.parse( 410 /** @type {string} */ (authRequest.parameters), 411 ); 412 413 return new Response( 414 renderConsentPage({ 415 clientName: clientMetadata.client_name || clientId, 416 clientId: clientId || '', 417 scope: parameters.scope || 'atproto', 418 requestUri: requestUri || '', 419 }), 420 { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 421 ); 422 } 423 424 // Direct authorization flow - create request on-the-fly 425 if (!clientId) { 426 return new Response('Missing client_id parameter', { status: 400 }); 427 } 428 429 const redirectUri = url.searchParams.get('redirect_uri'); 430 const responseType = url.searchParams.get('response_type'); 431 const responseMode = url.searchParams.get('response_mode'); 432 const scope = url.searchParams.get('scope'); 433 const state = url.searchParams.get('state'); 434 const codeChallenge = url.searchParams.get('code_challenge'); 435 const codeChallengeMethod = url.searchParams.get('code_challenge_method'); 436 const loginHint = url.searchParams.get('login_hint'); 437 438 // Validate parameters using shared helper 439 const validationResult = await this.validateAuthorizationParameters({ 440 clientId, 441 redirectUri, 442 responseType, 443 codeChallenge, 444 codeChallengeMethod, 445 }); 446 if ('error' in validationResult) return validationResult.error; 447 const { clientMetadata } = validationResult; 448 449 // Create authorization request record (same as PAR but without DPoP) 450 const requestId = crypto.randomUUID(); 451 const newRequestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; 452 const expiresIn = 600; 453 const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); 454 455 this.sql.exec( 456 `INSERT INTO authorization_requests ( 457 id, client_id, client_metadata, parameters, 458 code_challenge, code_challenge_method, dpop_jkt, 459 expires_at, created_at 460 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 461 requestId, 462 clientId, 463 JSON.stringify(clientMetadata), 464 JSON.stringify({ 465 redirect_uri: redirectUri, 466 scope, 467 state, 468 response_mode: responseMode, 469 login_hint: loginHint, 470 }), 471 codeChallenge, 472 codeChallengeMethod, 473 null, // No DPoP for direct authorization - will be bound at token exchange 474 expiresAt, 475 new Date().toISOString(), 476 ); 477 478 return new Response( 479 renderConsentPage({ 480 clientName: clientMetadata.client_name || clientId, 481 clientId: clientId, 482 scope: scope || 'atproto', 483 requestUri: newRequestUri, 484 }), 485 { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 486 ); 487 } 488``` 489 490**Step 2: Run the first direct auth test** 491 492Run: `npm test -- --grep "supports direct authorization without PAR"` 493 494Expected: PASS 495 496**Step 3: Commit** 497 498```bash 499git add src/pds.js 500git commit -m "feat: support direct authorization in handleOAuthAuthorizeGet" 501``` 502 503--- 504 505## Task 5: Update Token Endpoint for Null DPoP Binding 506 507**Files:** 508- Modify: `src/pds.js:4097-4098` (handleAuthCodeGrant method) 509 510**Step 1: Update DPoP validation to handle null dpop_jkt** 511 512Find the DPoP check in `handleAuthCodeGrant` (around line 4097) and replace: 513 514```javascript 515 if (authRequest.dpop_jkt !== dpop.jkt) 516 return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 517``` 518 519With: 520 521```javascript 522 // For PAR flow, dpop_jkt is set at PAR time and must match 523 // For direct authorization, dpop_jkt is null and we bind to the token request's DPoP 524 if (authRequest.dpop_jkt !== null && authRequest.dpop_jkt !== dpop.jkt) { 525 return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 526 } 527``` 528 529**Step 2: Run full direct auth flow test** 530 531Run: `npm test -- --grep "completes full direct authorization flow"` 532 533Expected: PASS 534 535**Step 3: Run all OAuth tests to verify nothing broke** 536 537Run: `npm test -- --grep OAuth` 538 539Expected: All OAuth tests PASS 540 541**Step 4: Commit** 542 543```bash 544git add src/pds.js 545git commit -m "feat: allow null dpop_jkt binding for direct authorization" 546``` 547 548--- 549 550## Task 6: Update AS Metadata 551 552**Files:** 553- Modify: `src/pds.js:3695` (handleOAuthAuthServerMetadata method) 554 555**Step 1: Change require_pushed_authorization_requests to false** 556 557Find line 3695 and change: 558 559```javascript 560 require_pushed_authorization_requests: true, 561``` 562 563To: 564 565```javascript 566 require_pushed_authorization_requests: false, 567``` 568 569**Step 2: Update the e2e test expectation** 570 571Find the AS metadata test in `test/e2e.test.js` (around line 541) and change: 572 573```javascript 574 assert.strictEqual(data.require_pushed_authorization_requests, true); 575``` 576 577To: 578 579```javascript 580 assert.strictEqual(data.require_pushed_authorization_requests, false); 581``` 582 583**Step 3: Run tests** 584 585Run: `npm test` 586 587Expected: All tests PASS 588 589**Step 4: Commit** 590 591```bash 592git add src/pds.js test/e2e.test.js 593git commit -m "feat: set require_pushed_authorization_requests to false" 594``` 595 596--- 597 598## Task 7: Final Verification 599 600**Step 1: Run all tests** 601 602Run: `npm test` 603 604Expected: All tests PASS 605 606**Step 2: Manual verification with the original URL** 607 608Test that the original failing URL now works by deploying to your worker and visiting: 609 610``` 611https://chad-pds.chad-53c.workers.dev/oauth/authorize?client_id=https%3A%2F%2Fquickslice-production-9cf4.up.railway.app%2Foauth-client-metadata.json&redirect_uri=https%3A%2F%2Fquickslice-production-9cf4.up.railway.app%2Foauth%2Fatp%2Fcallback&response_type=code&code_challenge=v9w-ACgE-QauiZkLpSDeZTjgGDmGdVHbegFe18dkQSw&code_challenge_method=S256&state=QkxYNYrf73X0rLaU6XBUyg&scope=atproto%20...&login_hint=did%3Aplc%3Ac6vxslynzebnlk5kw2orx37o 612``` 613 614Expected: Should show consent page instead of "Missing parameters" error 615 616**Step 3: Final commit (if any cleanup needed)** 617 618```bash 619git add -A 620git commit -m "chore: cleanup after direct authorization implementation" 621``` 622 623--- 624 625## Summary 626 627This implementation: 628 6291. **Extracts shared validation** - `validateAuthorizationParameters()` is used by both PAR and direct auth 6302. **Creates request records on-the-fly** - Direct auth creates the same DB record as PAR, just without DPoP binding 6313. **Defers DPoP binding** - For direct auth, DPoP is bound at token exchange time instead of request time 6324. **Updates metadata** - Sets `require_pushed_authorization_requests: false` to signal clients that PAR is optional 6335. **Maintains backwards compatibility** - PAR flow continues to work exactly as before