diff --git a/docs/plans/2026-01-08-direct-authorization.md b/docs/plans/2026-01-08-direct-authorization.md new file mode 100644 index 0000000..bc4c8bd --- /dev/null +++ b/docs/plans/2026-01-08-direct-authorization.md @@ -0,0 +1,633 @@ +# Direct Authorization Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Support direct OAuth authorization requests (without PAR) to match the official AT Protocol PDS behavior. + +**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. + +**Tech Stack:** JavaScript, Cloudflare Workers, SQLite + +--- + +## Task 1: Add Tests for Direct Authorization + +**Files:** +- Modify: `test/e2e.test.js` + +**Step 1: Write failing test for direct authorization GET** + +Add this test in the `OAuth endpoints` describe block (after existing OAuth tests around line 1452): + +```javascript + it('supports direct authorization without PAR', async () => { + const clientId = `http://localhost:${mockClientPort}/client-metadata.json`; + const redirectUri = `http://localhost:${mockClientPort}/callback`; + const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; + const codeChallenge = await generateCodeChallenge(codeVerifier); + const state = 'test-direct-auth-state'; + + // Step 1: GET authorize with direct parameters (no PAR) + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); + authorizeUrl.searchParams.set('client_id', clientId); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('scope', 'atproto'); + authorizeUrl.searchParams.set('code_challenge', codeChallenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('login_hint', DID); + + const getRes = await fetch(authorizeUrl.toString()); + assert.strictEqual(getRes.status, 200, 'Direct authorize GET should succeed'); + + const html = await getRes.text(); + assert.ok(html.includes('Authorize'), 'Should show consent page'); + assert.ok(html.includes('request_uri'), 'Should include request_uri in form'); + }); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --grep "supports direct authorization"` + +Expected: FAIL with "Direct authorize GET should succeed" - status will be 400 "Missing parameters" + +**Step 3: Add test for full direct auth flow** + +Add after the previous test: + +```javascript + it('completes full direct authorization flow', async () => { + const clientId = `http://localhost:${mockClientPort}/client-metadata.json`; + const redirectUri = `http://localhost:${mockClientPort}/callback`; + const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; + const codeChallenge = await generateCodeChallenge(codeVerifier); + const state = 'test-direct-auth-state'; + + // Step 1: GET authorize with direct parameters + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); + authorizeUrl.searchParams.set('client_id', clientId); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('scope', 'atproto'); + authorizeUrl.searchParams.set('code_challenge', codeChallenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('login_hint', DID); + + const getRes = await fetch(authorizeUrl.toString()); + assert.strictEqual(getRes.status, 200); + const html = await getRes.text(); + + // Extract request_uri from the form + const requestUriMatch = html.match(/name="request_uri" value="([^"]+)"/); + assert.ok(requestUriMatch, 'Should have request_uri in form'); + const requestUri = requestUriMatch[1]; + + // Step 2: POST to authorize (user approval) + const authRes = await fetch(`${BASE}/oauth/authorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + request_uri: requestUri, + client_id: clientId, + password: PASSWORD, + }).toString(), + redirect: 'manual', + }); + + assert.strictEqual(authRes.status, 302, 'Should redirect after approval'); + const location = authRes.headers.get('location'); + assert.ok(location, 'Should have Location header'); + const locationUrl = new URL(location); + const code = locationUrl.searchParams.get('code'); + assert.ok(code, 'Should have authorization code'); + assert.strictEqual(locationUrl.searchParams.get('state'), state); + + // Step 3: Exchange code for tokens + const { privateKey: dpopPrivateKey, publicJwk: dpopPublicJwk } = + await generateDpopKeyPair(); + const dpopProof = await createDpopProof( + dpopPrivateKey, + dpopPublicJwk, + 'POST', + `${BASE}/oauth/token`, + ); + + const tokenRes = await fetch(`${BASE}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + DPoP: dpopProof, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: codeVerifier, + }).toString(), + }); + + assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed'); + const tokenData = await tokenRes.json(); + assert.ok(tokenData.access_token, 'Should have access_token'); + assert.strictEqual(tokenData.token_type, 'DPoP'); + }); +``` + +**Step 4: Run tests to verify they fail** + +Run: `npm test -- --grep "direct authorization"` + +Expected: Both tests FAIL + +**Step 5: Commit test file** + +```bash +git add test/e2e.test.js +git commit -m "test: add failing tests for direct OAuth authorization flow" +``` + +--- + +## Task 2: Extract Shared Validation Logic + +**Files:** +- Modify: `src/pds.js:3737-3845` (handleOAuthPar method) + +**Step 1: Create validateAuthorizationParameters helper** + +Add this new method to the PersonalDataServer class, before `handleOAuthPar` (around line 3730): + +```javascript + /** + * Validate OAuth authorization request parameters. + * Shared between PAR and direct authorization flows. + * @param {Object} params - The authorization parameters + * @param {string} params.clientId - The client_id + * @param {string} params.redirectUri - The redirect_uri + * @param {string} params.responseType - The response_type + * @param {string} [params.responseMode] - The response_mode + * @param {string} [params.scope] - The scope + * @param {string} [params.state] - The state + * @param {string} params.codeChallenge - The code_challenge + * @param {string} params.codeChallengeMethod - The code_challenge_method + * @param {string} [params.loginHint] - The login_hint + * @returns {Promise<{error: Response} | {clientMetadata: ClientMetadata}>} + */ + async validateAuthorizationParameters({ + clientId, + redirectUri, + responseType, + codeChallenge, + codeChallengeMethod, + }) { + if (!clientId) { + return { error: errorResponse('invalid_request', 'client_id required', 400) }; + } + if (!redirectUri) { + return { error: errorResponse('invalid_request', 'redirect_uri required', 400) }; + } + if (responseType !== 'code') { + return { + error: errorResponse( + 'unsupported_response_type', + 'response_type must be code', + 400, + ), + }; + } + if (!codeChallenge || codeChallengeMethod !== 'S256') { + return { error: errorResponse('invalid_request', 'PKCE with S256 required', 400) }; + } + + let clientMetadata; + try { + clientMetadata = await getClientMetadata(clientId); + } catch (err) { + return { error: errorResponse('invalid_client', err.message, 400) }; + } + + // Validate redirect_uri against registered URIs + const isLoopback = + clientId.startsWith('http://localhost') || + clientId.startsWith('http://127.0.0.1'); + const redirectUriValid = clientMetadata.redirect_uris.some((uri) => { + if (isLoopback) { + try { + const registered = new URL(uri); + const requested = new URL(redirectUri); + return registered.origin === requested.origin; + } catch { + return false; + } + } + return uri === redirectUri; + }); + if (!redirectUriValid) { + return { + error: errorResponse( + 'invalid_request', + 'redirect_uri not registered for this client', + 400, + ), + }; + } + + return { clientMetadata }; + } +``` + +**Step 2: Run existing tests to verify nothing broke** + +Run: `npm test` + +Expected: All existing tests PASS (new method not called yet) + +**Step 3: Commit** + +```bash +git add src/pds.js +git commit -m "refactor: extract validateAuthorizationParameters helper" +``` + +--- + +## Task 3: Refactor handleOAuthPar to Use Shared Validation + +**Files:** +- Modify: `src/pds.js:3737-3845` (handleOAuthPar method) + +**Step 1: Update handleOAuthPar to use the new helper** + +Replace the validation section in `handleOAuthPar` (lines ~3760-3815) with: + +```javascript + async handleOAuthPar(request, url) { + // Opportunistically clean up expired authorization requests + this.cleanupExpiredAuthorizationRequests(); + + const issuer = `${url.protocol}//${url.host}`; + + const dpopResult = await this.validateRequiredDpop( + request, + 'POST', + `${issuer}/oauth/par`, + ); + if ('error' in dpopResult) return dpopResult.error; + const { dpop } = dpopResult; + + // Parse body - support both JSON and form-encoded + /** @type {Record} */ + let data; + try { + data = await parseRequestBody(request); + } catch { + return errorResponse('invalid_request', 'Invalid JSON body', 400); + } + + const clientId = data.client_id; + const redirectUri = data.redirect_uri; + const responseType = data.response_type; + const responseMode = data.response_mode; + const scope = data.scope; + const state = data.state; + const codeChallenge = data.code_challenge; + const codeChallengeMethod = data.code_challenge_method; + const loginHint = data.login_hint; + + // Use shared validation + const validationResult = await this.validateAuthorizationParameters({ + clientId, + redirectUri, + responseType, + codeChallenge, + codeChallengeMethod, + }); + if ('error' in validationResult) return validationResult.error; + const { clientMetadata } = validationResult; + + const requestId = crypto.randomUUID(); + const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; + const expiresIn = 600; + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); + + this.sql.exec( + `INSERT INTO authorization_requests ( + id, client_id, client_metadata, parameters, + code_challenge, code_challenge_method, dpop_jkt, + expires_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + requestId, + clientId, + JSON.stringify(clientMetadata), + JSON.stringify({ + redirect_uri: redirectUri, + scope, + state, + response_mode: responseMode, + login_hint: loginHint, + }), + codeChallenge, + codeChallengeMethod, + dpop.jkt, + expiresAt, + new Date().toISOString(), + ); + + return Response.json({ request_uri: requestUri, expires_in: expiresIn }); + } +``` + +**Step 2: Run all OAuth tests to verify PAR still works** + +Run: `npm test -- --grep OAuth` + +Expected: All existing OAuth tests PASS + +**Step 3: Commit** + +```bash +git add src/pds.js +git commit -m "refactor: use validateAuthorizationParameters in handleOAuthPar" +``` + +--- + +## Task 4: Implement Direct Authorization in handleOAuthAuthorizeGet + +**Files:** +- Modify: `src/pds.js:3869-3911` (handleOAuthAuthorizeGet method) + +**Step 1: Update handleOAuthAuthorizeGet to handle direct parameters** + +Replace the entire `handleOAuthAuthorizeGet` method: + +```javascript + /** + * Handle GET /oauth/authorize - displays the consent UI. + * Supports both PAR (request_uri) and direct authorization parameters. + * @param {URL} url - Parsed request URL + * @returns {Promise} HTML consent page + */ + async handleOAuthAuthorizeGet(url) { + // Opportunistically clean up expired authorization requests + this.cleanupExpiredAuthorizationRequests(); + + const requestUri = url.searchParams.get('request_uri'); + const clientId = url.searchParams.get('client_id'); + + // If request_uri is present, use PAR flow + if (requestUri) { + if (!clientId) { + return new Response('Missing client_id parameter', { status: 400 }); + } + + const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); + if (!match) return new Response('Invalid request_uri', { status: 400 }); + + const rows = this.sql + .exec( + `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, + match[1], + clientId, + ) + .toArray(); + const authRequest = rows[0]; + + if (!authRequest) return new Response('Request not found', { status: 400 }); + if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date()) + return new Response('Request expired', { status: 400 }); + if (authRequest.code) + return new Response('Request already used', { status: 400 }); + + const clientMetadata = JSON.parse( + /** @type {string} */ (authRequest.client_metadata), + ); + const parameters = JSON.parse( + /** @type {string} */ (authRequest.parameters), + ); + + return new Response( + renderConsentPage({ + clientName: clientMetadata.client_name || clientId, + clientId: clientId || '', + scope: parameters.scope || 'atproto', + requestUri: requestUri || '', + }), + { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, + ); + } + + // Direct authorization flow - create request on-the-fly + if (!clientId) { + return new Response('Missing client_id parameter', { status: 400 }); + } + + const redirectUri = url.searchParams.get('redirect_uri'); + const responseType = url.searchParams.get('response_type'); + const responseMode = url.searchParams.get('response_mode'); + const scope = url.searchParams.get('scope'); + const state = url.searchParams.get('state'); + const codeChallenge = url.searchParams.get('code_challenge'); + const codeChallengeMethod = url.searchParams.get('code_challenge_method'); + const loginHint = url.searchParams.get('login_hint'); + + // Validate parameters using shared helper + const validationResult = await this.validateAuthorizationParameters({ + clientId, + redirectUri, + responseType, + codeChallenge, + codeChallengeMethod, + }); + if ('error' in validationResult) return validationResult.error; + const { clientMetadata } = validationResult; + + // Create authorization request record (same as PAR but without DPoP) + const requestId = crypto.randomUUID(); + const newRequestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; + const expiresIn = 600; + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); + + this.sql.exec( + `INSERT INTO authorization_requests ( + id, client_id, client_metadata, parameters, + code_challenge, code_challenge_method, dpop_jkt, + expires_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + requestId, + clientId, + JSON.stringify(clientMetadata), + JSON.stringify({ + redirect_uri: redirectUri, + scope, + state, + response_mode: responseMode, + login_hint: loginHint, + }), + codeChallenge, + codeChallengeMethod, + null, // No DPoP for direct authorization - will be bound at token exchange + expiresAt, + new Date().toISOString(), + ); + + return new Response( + renderConsentPage({ + clientName: clientMetadata.client_name || clientId, + clientId: clientId, + scope: scope || 'atproto', + requestUri: newRequestUri, + }), + { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, + ); + } +``` + +**Step 2: Run the first direct auth test** + +Run: `npm test -- --grep "supports direct authorization without PAR"` + +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/pds.js +git commit -m "feat: support direct authorization in handleOAuthAuthorizeGet" +``` + +--- + +## Task 5: Update Token Endpoint for Null DPoP Binding + +**Files:** +- Modify: `src/pds.js:4097-4098` (handleAuthCodeGrant method) + +**Step 1: Update DPoP validation to handle null dpop_jkt** + +Find the DPoP check in `handleAuthCodeGrant` (around line 4097) and replace: + +```javascript + if (authRequest.dpop_jkt !== dpop.jkt) + return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); +``` + +With: + +```javascript + // For PAR flow, dpop_jkt is set at PAR time and must match + // For direct authorization, dpop_jkt is null and we bind to the token request's DPoP + if (authRequest.dpop_jkt !== null && authRequest.dpop_jkt !== dpop.jkt) { + return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); + } +``` + +**Step 2: Run full direct auth flow test** + +Run: `npm test -- --grep "completes full direct authorization flow"` + +Expected: PASS + +**Step 3: Run all OAuth tests to verify nothing broke** + +Run: `npm test -- --grep OAuth` + +Expected: All OAuth tests PASS + +**Step 4: Commit** + +```bash +git add src/pds.js +git commit -m "feat: allow null dpop_jkt binding for direct authorization" +``` + +--- + +## Task 6: Update AS Metadata + +**Files:** +- Modify: `src/pds.js:3695` (handleOAuthAuthServerMetadata method) + +**Step 1: Change require_pushed_authorization_requests to false** + +Find line 3695 and change: + +```javascript + require_pushed_authorization_requests: true, +``` + +To: + +```javascript + require_pushed_authorization_requests: false, +``` + +**Step 2: Update the e2e test expectation** + +Find the AS metadata test in `test/e2e.test.js` (around line 541) and change: + +```javascript + assert.strictEqual(data.require_pushed_authorization_requests, true); +``` + +To: + +```javascript + assert.strictEqual(data.require_pushed_authorization_requests, false); +``` + +**Step 3: Run tests** + +Run: `npm test` + +Expected: All tests PASS + +**Step 4: Commit** + +```bash +git add src/pds.js test/e2e.test.js +git commit -m "feat: set require_pushed_authorization_requests to false" +``` + +--- + +## Task 7: Final Verification + +**Step 1: Run all tests** + +Run: `npm test` + +Expected: All tests PASS + +**Step 2: Manual verification with the original URL** + +Test that the original failing URL now works by deploying to your worker and visiting: + +``` +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 +``` + +Expected: Should show consent page instead of "Missing parameters" error + +**Step 3: Final commit (if any cleanup needed)** + +```bash +git add -A +git commit -m "chore: cleanup after direct authorization implementation" +``` + +--- + +## Summary + +This implementation: + +1. **Extracts shared validation** - `validateAuthorizationParameters()` is used by both PAR and direct auth +2. **Creates request records on-the-fly** - Direct auth creates the same DB record as PAR, just without DPoP binding +3. **Defers DPoP binding** - For direct auth, DPoP is bound at token exchange time instead of request time +4. **Updates metadata** - Sets `require_pushed_authorization_requests: false` to signal clients that PAR is optional +5. **Maintains backwards compatibility** - PAR flow continues to work exactly as before