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