# 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