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