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

docs: add implementation plan for direct OAuth authorization support

Changed files
+633
docs
+633
docs/plans/2026-01-08-direct-authorization.md
··· 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 + 20 + Add 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 + 52 + Run: `npm test -- --grep "supports direct authorization"` 53 + 54 + Expected: 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 + 58 + Add 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 + 142 + Run: `npm test -- --grep "direct authorization"` 143 + 144 + Expected: Both tests FAIL 145 + 146 + **Step 5: Commit test file** 147 + 148 + ```bash 149 + git add test/e2e.test.js 150 + git 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 + 162 + Add 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 + 245 + Run: `npm test` 246 + 247 + Expected: All existing tests PASS (new method not called yet) 248 + 249 + **Step 3: Commit** 250 + 251 + ```bash 252 + git add src/pds.js 253 + git 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 + 265 + Replace 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 + 346 + Run: `npm test -- --grep OAuth` 347 + 348 + Expected: All existing OAuth tests PASS 349 + 350 + **Step 3: Commit** 351 + 352 + ```bash 353 + git add src/pds.js 354 + git 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 + 366 + Replace 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 + 492 + Run: `npm test -- --grep "supports direct authorization without PAR"` 493 + 494 + Expected: PASS 495 + 496 + **Step 3: Commit** 497 + 498 + ```bash 499 + git add src/pds.js 500 + git 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 + 512 + Find 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 + 519 + With: 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 + 531 + Run: `npm test -- --grep "completes full direct authorization flow"` 532 + 533 + Expected: PASS 534 + 535 + **Step 3: Run all OAuth tests to verify nothing broke** 536 + 537 + Run: `npm test -- --grep OAuth` 538 + 539 + Expected: All OAuth tests PASS 540 + 541 + **Step 4: Commit** 542 + 543 + ```bash 544 + git add src/pds.js 545 + git 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 + 557 + Find line 3695 and change: 558 + 559 + ```javascript 560 + require_pushed_authorization_requests: true, 561 + ``` 562 + 563 + To: 564 + 565 + ```javascript 566 + require_pushed_authorization_requests: false, 567 + ``` 568 + 569 + **Step 2: Update the e2e test expectation** 570 + 571 + Find 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 + 577 + To: 578 + 579 + ```javascript 580 + assert.strictEqual(data.require_pushed_authorization_requests, false); 581 + ``` 582 + 583 + **Step 3: Run tests** 584 + 585 + Run: `npm test` 586 + 587 + Expected: All tests PASS 588 + 589 + **Step 4: Commit** 590 + 591 + ```bash 592 + git add src/pds.js test/e2e.test.js 593 + git 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 + 602 + Run: `npm test` 603 + 604 + Expected: All tests PASS 605 + 606 + **Step 2: Manual verification with the original URL** 607 + 608 + Test that the original failing URL now works by deploying to your worker and visiting: 609 + 610 + ``` 611 + https://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 + 614 + Expected: Should show consent page instead of "Missing parameters" error 615 + 616 + **Step 3: Final commit (if any cleanup needed)** 617 + 618 + ```bash 619 + git add -A 620 + git commit -m "chore: cleanup after direct authorization implementation" 621 + ``` 622 + 623 + --- 624 + 625 + ## Summary 626 + 627 + This implementation: 628 + 629 + 1. **Extracts shared validation** - `validateAuthorizationParameters()` is used by both PAR and direct auth 630 + 2. **Creates request records on-the-fly** - Direct auth creates the same DB record as PAR, just without DPoP binding 631 + 3. **Defers DPoP binding** - For direct auth, DPoP is bound at token exchange time instead of request time 632 + 4. **Updates metadata** - Sets `require_pushed_authorization_requests: false` to signal clients that PAR is optional 633 + 5. **Maintains backwards compatibility** - PAR flow continues to work exactly as before