this repo has no description
1# OAuth Implementation Plan
2
3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
5**Goal:** Add full AT Protocol OAuth support (PAR, DPoP, PKCE, authorization code flow) to pds.js while maintaining zero external dependencies.
6
7**Architecture:** Extend the existing single-file pds.js with OAuth endpoints. Store authorization requests and tokens in SQLite. Use Web Crypto APIs for all cryptographic operations. Minimal server-rendered HTML for consent UI.
8
9**Tech Stack:** JavaScript (Cloudflare Workers), SQLite (Durable Objects), Web Crypto API, P-256/ES256 signatures.
10
11---
12
13## Task 1: Add OAuth Database Tables
14
15**Files:**
16- Modify: `src/pds.js`
17
18**Step 1: Add tables to initializeDatabase**
19
20In `src/pds.js`, add to the `initializeDatabase` function after existing table creation:
21
22```javascript
23// OAuth authorization requests (from PAR)
24await sql`
25 CREATE TABLE IF NOT EXISTS authorization_requests (
26 id TEXT PRIMARY KEY,
27 client_id TEXT NOT NULL,
28 client_metadata TEXT NOT NULL,
29 parameters TEXT NOT NULL,
30 code TEXT,
31 code_challenge TEXT,
32 code_challenge_method TEXT,
33 dpop_jkt TEXT,
34 did TEXT,
35 expires_at TEXT NOT NULL,
36 created_at TEXT NOT NULL
37 )
38`;
39
40await sql`
41 CREATE INDEX IF NOT EXISTS idx_authorization_requests_code
42 ON authorization_requests(code) WHERE code IS NOT NULL
43`;
44
45// OAuth tokens
46await sql`
47 CREATE TABLE IF NOT EXISTS tokens (
48 id INTEGER PRIMARY KEY AUTOINCREMENT,
49 token_id TEXT UNIQUE NOT NULL,
50 did TEXT NOT NULL,
51 client_id TEXT NOT NULL,
52 scope TEXT,
53 dpop_jkt TEXT,
54 expires_at TEXT NOT NULL,
55 refresh_token TEXT UNIQUE,
56 created_at TEXT NOT NULL,
57 updated_at TEXT NOT NULL
58 )
59`;
60
61await sql`
62 CREATE INDEX IF NOT EXISTS idx_tokens_did ON tokens(did)
63`;
64```
65
66**Step 2: Commit**
67
68```bash
69git add src/pds.js
70git commit -m "feat(oauth): add authorization_requests and tokens tables"
71```
72
73---
74
75## Task 2: Implement JWK Thumbprint
76
77**Files:**
78- Modify: `src/pds.js`
79- Test: `test/pds.test.js`
80
81**Step 1: Add unit test**
82
83Add to `test/pds.test.js` imports and test:
84
85```javascript
86import {
87 // ... existing imports ...
88 computeJwkThumbprint,
89} from '../src/pds.js';
90
91describe('JWK Thumbprint', () => {
92 test('computes deterministic thumbprint for EC key', async () => {
93 // Test vector: known JWK and its expected thumbprint
94 const jwk = {
95 kty: 'EC',
96 crv: 'P-256',
97 x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY',
98 y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ'
99 };
100
101 const jkt1 = await computeJwkThumbprint(jwk);
102 const jkt2 = await computeJwkThumbprint(jwk);
103
104 // Thumbprint must be deterministic
105 assert.strictEqual(jkt1, jkt2);
106 // Must be base64url-encoded SHA-256 (43 chars)
107 assert.strictEqual(jkt1.length, 43);
108 // Must only contain base64url characters
109 assert.match(jkt1, /^[A-Za-z0-9_-]+$/);
110 });
111
112 test('produces different thumbprints for different keys', async () => {
113 const jwk1 = { kty: 'EC', crv: 'P-256', x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ' };
114 const jwk2 = { kty: 'EC', crv: 'P-256', x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0' };
115
116 const jkt1 = await computeJwkThumbprint(jwk1);
117 const jkt2 = await computeJwkThumbprint(jwk2);
118
119 assert.notStrictEqual(jkt1, jkt2);
120 });
121});
122```
123
124**Step 2: Implement and export**
125
126Add to `src/pds.js`:
127
128```javascript
129/**
130 * Compute JWK thumbprint (SHA-256) per RFC 7638.
131 * Creates a canonical JSON representation of EC key required members
132 * and returns the base64url-encoded SHA-256 hash.
133 * @param {{ kty: string, crv: string, x: string, y: string }} jwk - The EC public key in JWK format
134 * @returns {Promise<string>} The base64url-encoded thumbprint
135 */
136export async function computeJwkThumbprint(jwk) {
137 // RFC 7638: members must be in lexicographic order
138 const thumbprintInput = JSON.stringify({
139 crv: jwk.crv,
140 kty: jwk.kty,
141 x: jwk.x,
142 y: jwk.y
143 });
144 const hash = await crypto.subtle.digest(
145 'SHA-256',
146 new TextEncoder().encode(thumbprintInput)
147 );
148 return base64UrlEncode(new Uint8Array(hash));
149}
150```
151
152**Step 3: Run tests and commit**
153
154```bash
155npm test
156git add src/pds.js test/pds.test.js
157git commit -m "feat(oauth): implement JWK thumbprint computation"
158```
159
160---
161
162## Task 3: Implement Client Metadata Validation
163
164**Files:**
165- Modify: `src/pds.js`
166- Test: `test/pds.test.js`
167
168**Step 1: Add unit tests**
169
170```javascript
171import {
172 // ... existing imports ...
173 isLoopbackClient,
174 getLoopbackClientMetadata,
175 validateClientMetadata,
176} from '../src/pds.js';
177
178describe('Client Metadata', () => {
179 test('isLoopbackClient detects localhost', () => {
180 assert.strictEqual(isLoopbackClient('http://localhost:8080'), true);
181 assert.strictEqual(isLoopbackClient('http://127.0.0.1:3000'), true);
182 assert.strictEqual(isLoopbackClient('https://example.com'), false);
183 });
184
185 test('getLoopbackClientMetadata returns permissive defaults', () => {
186 const metadata = getLoopbackClientMetadata('http://localhost:8080');
187 assert.strictEqual(metadata.client_id, 'http://localhost:8080');
188 assert.ok(metadata.grant_types.includes('authorization_code'));
189 assert.strictEqual(metadata.dpop_bound_access_tokens, true);
190 });
191
192 test('validateClientMetadata rejects mismatched client_id', () => {
193 const metadata = {
194 client_id: 'https://other.com/metadata.json',
195 redirect_uris: ['https://example.com/callback'],
196 grant_types: ['authorization_code'],
197 response_types: ['code']
198 };
199 assert.throws(
200 () => validateClientMetadata(metadata, 'https://example.com/metadata.json'),
201 /client_id mismatch/
202 );
203 });
204});
205```
206
207**Step 2: Implement functions**
208
209```javascript
210/**
211 * Check if a client_id represents a loopback client (localhost development).
212 * Loopback clients are allowed without pre-registration per AT Protocol OAuth spec.
213 * @param {string} clientId - The client_id to check
214 * @returns {boolean} True if the client_id is a loopback address
215 */
216export function isLoopbackClient(clientId) {
217 try {
218 const url = new URL(clientId);
219 const host = url.hostname.toLowerCase();
220 return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
221 } catch {
222 return false;
223 }
224}
225
226/**
227 * @typedef {Object} ClientMetadata
228 * @property {string} client_id - The client identifier (must match the URL used to fetch metadata)
229 * @property {string} [client_name] - Human-readable client name
230 * @property {string[]} redirect_uris - Allowed redirect URIs
231 * @property {string[]} grant_types - Supported grant types
232 * @property {string[]} response_types - Supported response types
233 * @property {string} [token_endpoint_auth_method] - Token endpoint auth method
234 * @property {boolean} [dpop_bound_access_tokens] - Whether client requires DPoP-bound tokens
235 * @property {string} [scope] - Default scope
236 */
237
238/**
239 * Generate permissive client metadata for a loopback client.
240 * @param {string} clientId - The loopback client_id
241 * @returns {ClientMetadata} Generated client metadata
242 */
243export function getLoopbackClientMetadata(clientId) {
244 return {
245 client_id: clientId,
246 client_name: 'Loopback Client',
247 redirect_uris: [clientId],
248 grant_types: ['authorization_code', 'refresh_token'],
249 response_types: ['code'],
250 token_endpoint_auth_method: 'none',
251 dpop_bound_access_tokens: true,
252 scope: 'atproto'
253 };
254}
255
256/**
257 * Validate client metadata against AT Protocol OAuth requirements.
258 * @param {ClientMetadata} metadata - The client metadata to validate
259 * @param {string} expectedClientId - The expected client_id (the URL used to fetch metadata)
260 * @throws {Error} If validation fails
261 */
262export function validateClientMetadata(metadata, expectedClientId) {
263 if (!metadata.client_id) throw new Error('client_id is required');
264 if (metadata.client_id !== expectedClientId) throw new Error('client_id mismatch');
265 if (!Array.isArray(metadata.redirect_uris) || metadata.redirect_uris.length === 0) {
266 throw new Error('redirect_uris is required');
267 }
268 if (!metadata.grant_types?.includes('authorization_code')) {
269 throw new Error('grant_types must include authorization_code');
270 }
271}
272
273/** @type {Map<string, { metadata: ClientMetadata, expiresAt: number }>} */
274const clientMetadataCache = new Map();
275
276/**
277 * Fetch and validate client metadata from a client_id URL.
278 * Caches results for 10 minutes. Loopback clients return synthetic metadata.
279 * @param {string} clientId - The client_id (URL to fetch metadata from)
280 * @returns {Promise<ClientMetadata>} The validated client metadata
281 * @throws {Error} If fetching or validation fails
282 */
283async function getClientMetadata(clientId) {
284 const cached = clientMetadataCache.get(clientId);
285 if (cached && Date.now() < cached.expiresAt) return cached.metadata;
286
287 if (isLoopbackClient(clientId)) {
288 const metadata = getLoopbackClientMetadata(clientId);
289 clientMetadataCache.set(clientId, { metadata, expiresAt: Date.now() + 600000 });
290 return metadata;
291 }
292
293 const response = await fetch(clientId, { headers: { 'Accept': 'application/json' } });
294 if (!response.ok) throw new Error(`Failed to fetch client metadata: ${response.status}`);
295
296 const metadata = await response.json();
297 validateClientMetadata(metadata, clientId);
298 clientMetadataCache.set(clientId, { metadata, expiresAt: Date.now() + 600000 });
299 return metadata;
300}
301```
302
303**Step 3: Run tests and commit**
304
305```bash
306npm test
307git add src/pds.js test/pds.test.js
308git commit -m "feat(oauth): implement client metadata fetching and validation"
309```
310
311---
312
313## Task 4: Implement DPoP Proof Parsing
314
315**Files:**
316- Modify: `src/pds.js`
317
318**Step 1: Implement parseDpopProof**
319
320```javascript
321/**
322 * @typedef {Object} DpopProofResult
323 * @property {string} jkt - The JWK thumbprint of the DPoP key
324 * @property {string} jti - The unique identifier from the DPoP proof
325 * @property {{ kty: string, crv: string, x: string, y: string }} jwk - The public key from the proof
326 */
327
328/**
329 * Parse and validate a DPoP proof JWT.
330 * Verifies the signature, checks claims (htm, htu, iat, jti), and optionally
331 * validates key binding (expectedJkt) and access token hash (ath).
332 * @param {string} proof - The DPoP proof JWT
333 * @param {string} method - The expected HTTP method (htm claim)
334 * @param {string} url - The expected request URL (htu claim)
335 * @param {string|null} [expectedJkt=null] - If provided, verify the key matches this thumbprint
336 * @param {string|null} [accessToken=null] - If provided, verify the ath claim matches this token's hash
337 * @returns {Promise<DpopProofResult>} The parsed proof with jkt, jti, and jwk
338 * @throws {Error} If validation fails
339 */
340async function parseDpopProof(proof, method, url, expectedJkt = null, accessToken = null) {
341 const parts = proof.split('.');
342 if (parts.length !== 3) throw new Error('Invalid DPoP proof format');
343
344 const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0])));
345 const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1])));
346
347 if (header.typ !== 'dpop+jwt') throw new Error('DPoP proof must have typ dpop+jwt');
348 if (header.alg !== 'ES256') throw new Error('DPoP proof must use ES256');
349 if (!header.jwk || header.jwk.kty !== 'EC') throw new Error('DPoP proof must contain EC key');
350
351 // Verify signature
352 const publicKey = await crypto.subtle.importKey(
353 'jwk', header.jwk,
354 { name: 'ECDSA', namedCurve: 'P-256' },
355 false, ['verify']
356 );
357
358 const signatureInput = new TextEncoder().encode(parts[0] + '.' + parts[1]);
359 const signature = base64UrlDecode(parts[2]);
360 const derSignature = compactSignatureToDer(signature);
361
362 const valid = await crypto.subtle.verify(
363 { name: 'ECDSA', hash: 'SHA-256' },
364 publicKey, derSignature, signatureInput
365 );
366 if (!valid) throw new Error('DPoP proof signature invalid');
367
368 // Validate claims
369 if (payload.htm !== method) throw new Error('DPoP htm mismatch');
370
371 const normalizeUrl = (u) => u.replace(/\/$/, '').split('?')[0].toLowerCase();
372 if (normalizeUrl(payload.htu) !== normalizeUrl(url)) throw new Error('DPoP htu mismatch');
373
374 const now = Math.floor(Date.now() / 1000);
375 if (!payload.iat || payload.iat > now + 60 || payload.iat < now - 300) {
376 throw new Error('DPoP proof expired or invalid iat');
377 }
378
379 if (!payload.jti) throw new Error('DPoP proof missing jti');
380
381 const jkt = await computeJwkThumbprint(header.jwk);
382 if (expectedJkt && jkt !== expectedJkt) throw new Error('DPoP key mismatch');
383
384 if (accessToken) {
385 const tokenHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken));
386 const expectedAth = base64UrlEncode(new Uint8Array(tokenHash));
387 if (payload.ath !== expectedAth) throw new Error('DPoP ath mismatch');
388 }
389
390 return { jkt, jti: payload.jti, jwk: header.jwk };
391}
392
393/**
394 * Convert a compact (r||s) ECDSA signature to DER format for Web Crypto API.
395 * @param {Uint8Array} compact - The 64-byte compact signature (32 bytes r + 32 bytes s)
396 * @returns {Uint8Array} The DER-encoded signature
397 */
398function compactSignatureToDer(compact) {
399 const r = compact.slice(0, 32);
400 const s = compact.slice(32, 64);
401
402 /**
403 * @param {Uint8Array} bytes
404 * @returns {Uint8Array}
405 */
406 function encodeInt(bytes) {
407 let i = 0;
408 while (i < bytes.length - 1 && bytes[i] === 0 && !(bytes[i + 1] & 0x80)) i++;
409 const trimmed = bytes.slice(i);
410 if (trimmed[0] & 0x80) return new Uint8Array([0x02, trimmed.length + 1, 0, ...trimmed]);
411 return new Uint8Array([0x02, trimmed.length, ...trimmed]);
412 }
413
414 const rDer = encodeInt(r);
415 const sDer = encodeInt(s);
416 return new Uint8Array([0x30, rDer.length + sDer.length, ...rDer, ...sDer]);
417}
418```
419
420**Step 2: Commit**
421
422```bash
423git add src/pds.js
424git commit -m "feat(oauth): implement DPoP proof parsing"
425```
426
427---
428
429## Task 5: Add OAuth Discovery Endpoints
430
431**Files:**
432- Modify: `src/pds.js`
433
434**Step 1: Add endpoints to handleRequest**
435
436```javascript
437// OAuth Authorization Server Metadata
438if (path === '/.well-known/oauth-authorization-server' && method === 'GET') {
439 const issuer = `${url.protocol}//${url.host}`;
440 return json({
441 issuer,
442 authorization_endpoint: `${issuer}/oauth/authorize`,
443 token_endpoint: `${issuer}/oauth/token`,
444 revocation_endpoint: `${issuer}/oauth/revoke`,
445 pushed_authorization_request_endpoint: `${issuer}/oauth/par`,
446 jwks_uri: `${issuer}/oauth/jwks`,
447 scopes_supported: ['atproto'],
448 response_types_supported: ['code'],
449 grant_types_supported: ['authorization_code', 'refresh_token'],
450 code_challenge_methods_supported: ['S256'],
451 token_endpoint_auth_methods_supported: ['none'],
452 dpop_signing_alg_values_supported: ['ES256'],
453 require_pushed_authorization_requests: true,
454 authorization_response_iss_parameter_supported: true
455 });
456}
457
458// OAuth Protected Resource Metadata
459if (path === '/.well-known/oauth-protected-resource' && method === 'GET') {
460 const resource = `${url.protocol}//${url.host}`;
461 return json({
462 resource,
463 authorization_servers: [resource],
464 bearer_methods_supported: ['header'],
465 scopes_supported: ['atproto']
466 });
467}
468
469// JWKS endpoint
470if (path === '/oauth/jwks' && method === 'GET') {
471 const publicKeyJwk = await getPublicKeyJwk(this);
472 return json({
473 keys: [{ ...publicKeyJwk, kid: 'pds-oauth-key', use: 'sig', alg: 'ES256' }]
474 });
475}
476```
477
478**Step 2: Add getPublicKeyJwk helper**
479
480```javascript
481/**
482 * Get the PDS signing key as a public JWK.
483 * Exports only the public components (kty, crv, x, y) for use in JWKS.
484 * @param {{ storage: DurableObjectStorage }} pds - The PDS instance
485 * @returns {Promise<{ kty: string, crv: string, x: string, y: string }>} The public key in JWK format
486 * @throws {Error} If the PDS is not initialized
487 */
488async function getPublicKeyJwk(pds) {
489 const privateKeyHex = await pds.storage.get('privateKey');
490 if (!privateKeyHex) throw new Error('PDS not initialized');
491
492 const privateKeyBytes = hexToBytes(privateKeyHex);
493 const privateKey = await crypto.subtle.importKey(
494 'pkcs8', privateKeyBytes,
495 { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']
496 );
497 const jwk = await crypto.subtle.exportKey('jwk', privateKey);
498 return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
499}
500```
501
502**Step 3: Commit**
503
504```bash
505git add src/pds.js
506git commit -m "feat(oauth): add discovery endpoints"
507```
508
509---
510
511## Task 6: Implement PAR Endpoint
512
513**Files:**
514- Modify: `src/pds.js`
515
516**Step 1: Add PAR handler**
517
518```javascript
519if (path === '/oauth/par' && method === 'POST') {
520 return handlePar(request, url, this, env);
521}
522
523/**
524 * Handle Pushed Authorization Request (PAR) endpoint.
525 * Validates DPoP proof, client metadata, PKCE parameters, and stores the authorization request.
526 * @param {Request} request - The incoming request
527 * @param {URL} url - Parsed request URL
528 * @param {{ storage: DurableObjectStorage }} pds - The PDS instance
529 * @param {{ PDS_PASSWORD: string }} env - Environment variables
530 * @returns {Promise<Response>} JSON response with request_uri and expires_in
531 */
532async function handlePar(request, url, pds, env) {
533 const issuer = `${url.protocol}//${url.host}`;
534
535 const dpopHeader = request.headers.get('DPoP');
536 if (!dpopHeader) {
537 return json({ error: 'invalid_dpop_proof', error_description: 'DPoP proof required' }, 400);
538 }
539
540 let dpop;
541 try {
542 dpop = await parseDpopProof(dpopHeader, 'POST', `${issuer}/oauth/par`);
543 } catch (err) {
544 return json({ error: 'invalid_dpop_proof', error_description: err.message }, 400);
545 }
546
547 const body = await request.text();
548 const params = new URLSearchParams(body);
549
550 const clientId = params.get('client_id');
551 const redirectUri = params.get('redirect_uri');
552 const responseType = params.get('response_type');
553 const scope = params.get('scope');
554 const state = params.get('state');
555 const codeChallenge = params.get('code_challenge');
556 const codeChallengeMethod = params.get('code_challenge_method');
557
558 if (!clientId) return json({ error: 'invalid_request', error_description: 'client_id required' }, 400);
559 if (!redirectUri) return json({ error: 'invalid_request', error_description: 'redirect_uri required' }, 400);
560 if (responseType !== 'code') return json({ error: 'unsupported_response_type' }, 400);
561 if (!codeChallenge || codeChallengeMethod !== 'S256') {
562 return json({ error: 'invalid_request', error_description: 'PKCE with S256 required' }, 400);
563 }
564
565 let clientMetadata;
566 try {
567 clientMetadata = await getClientMetadata(clientId);
568 } catch (err) {
569 return json({ error: 'invalid_client', error_description: err.message }, 400);
570 }
571
572 const requestId = crypto.randomUUID();
573 const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
574 const expiresIn = 600;
575 const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
576
577 const sql = createSql(pds.storage);
578 await sql`
579 INSERT INTO authorization_requests (
580 id, client_id, client_metadata, parameters,
581 code_challenge, code_challenge_method, dpop_jkt,
582 expires_at, created_at
583 ) VALUES (
584 ${requestId}, ${clientId}, ${JSON.stringify(clientMetadata)},
585 ${JSON.stringify({ redirect_uri: redirectUri, scope, state })},
586 ${codeChallenge}, ${codeChallengeMethod}, ${dpop.jkt},
587 ${expiresAt}, ${new Date().toISOString()}
588 )
589 `;
590
591 return json({ request_uri: requestUri, expires_in: expiresIn });
592}
593```
594
595**Step 2: Commit**
596
597```bash
598git add src/pds.js
599git commit -m "feat(oauth): implement PAR endpoint"
600```
601
602---
603
604## Task 7: Implement Authorization Endpoint
605
606**Files:**
607- Modify: `src/pds.js`
608
609**Step 1: Add GET handler (consent UI)**
610
611```javascript
612if (path === '/oauth/authorize' && method === 'GET') {
613 return handleAuthorizeGet(request, url, this, env);
614}
615
616/**
617 * Handle GET /oauth/authorize - displays the consent UI.
618 * Validates the request_uri from PAR and renders a login/consent form.
619 * @param {Request} request - The incoming request
620 * @param {URL} url - Parsed request URL
621 * @param {{ storage: DurableObjectStorage }} pds - The PDS instance
622 * @param {{ PDS_PASSWORD: string }} env - Environment variables
623 * @returns {Promise<Response>} HTML consent page
624 */
625async function handleAuthorizeGet(request, url, pds, env) {
626 const requestUri = url.searchParams.get('request_uri');
627 const clientId = url.searchParams.get('client_id');
628
629 if (!requestUri || !clientId) return new Response('Missing parameters', { status: 400 });
630
631 const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/);
632 if (!match) return new Response('Invalid request_uri', { status: 400 });
633
634 const sql = createSql(pds.storage);
635 const [authRequest] = await sql`
636 SELECT * FROM authorization_requests WHERE id = ${match[1]} AND client_id = ${clientId}
637 `;
638
639 if (!authRequest) return new Response('Request not found', { status: 400 });
640 if (new Date(authRequest.expires_at) < new Date()) return new Response('Request expired', { status: 400 });
641 if (authRequest.code) return new Response('Request already used', { status: 400 });
642
643 const clientMetadata = JSON.parse(authRequest.client_metadata);
644 const parameters = JSON.parse(authRequest.parameters);
645
646 return new Response(renderConsentPage({
647 clientName: clientMetadata.client_name || clientId,
648 clientId, scope: parameters.scope || 'atproto', requestUri
649 }), { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
650}
651
652/**
653 * Render the OAuth consent page HTML.
654 * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params
655 * @returns {string} HTML page content
656 */
657function renderConsentPage({ clientName, clientId, scope, requestUri, error = '' }) {
658 /** @param {string} s */
659 const escHtml = s => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
660 return `<!DOCTYPE html>
661<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
662<title>Authorize</title>
663<style>body{font-family:system-ui;max-width:400px;margin:40px auto;padding:20px}
664.error{color:#c00;background:#fee;padding:10px;margin:10px 0}
665button{padding:10px 20px;margin:5px;cursor:pointer}
666.approve{background:#06c;color:#fff;border:none}
667input{width:100%;padding:8px;margin:5px 0;box-sizing:border-box}</style></head>
668<body><h2>Sign in to authorize</h2>
669<p><b>${escHtml(clientName)}</b> wants to access your account.</p>
670<p>Scope: ${escHtml(scope)}</p>
671${error ? `<p class="error">${escHtml(error)}</p>` : ''}
672<form method="POST" action="/oauth/authorize">
673<input type="hidden" name="request_uri" value="${escHtml(requestUri)}">
674<input type="hidden" name="client_id" value="${escHtml(clientId)}">
675<label>Password</label><input type="password" name="password" required autofocus>
676<div><button type="submit" name="action" value="deny">Deny</button>
677<button type="submit" name="action" value="approve" class="approve">Authorize</button></div>
678</form></body></html>`;
679}
680```
681
682**Step 2: Add POST handler (approval)**
683
684```javascript
685if (path === '/oauth/authorize' && method === 'POST') {
686 return handleAuthorizePost(request, url, this, env);
687}
688
689/**
690 * Handle POST /oauth/authorize - processes user approval/denial.
691 * Validates password, generates authorization code on approval, redirects to client.
692 * @param {Request} request - The incoming request
693 * @param {URL} url - Parsed request URL
694 * @param {{ storage: DurableObjectStorage }} pds - The PDS instance
695 * @param {{ PDS_PASSWORD: string }} env - Environment variables
696 * @returns {Promise<Response>} Redirect to client redirect_uri with code or error
697 */
698async function handleAuthorizePost(request, url, pds, env) {
699 const issuer = `${url.protocol}//${url.host}`;
700 const body = await request.text();
701 const params = new URLSearchParams(body);
702
703 const requestUri = params.get('request_uri');
704 const clientId = params.get('client_id');
705 const password = params.get('password');
706 const action = params.get('action');
707
708 const match = requestUri?.match(/^urn:ietf:params:oauth:request_uri:(.+)$/);
709 if (!match) return new Response('Invalid request_uri', { status: 400 });
710
711 const sql = createSql(pds.storage);
712 const [authRequest] = await sql`
713 SELECT * FROM authorization_requests WHERE id = ${match[1]} AND client_id = ${clientId}
714 `;
715 if (!authRequest) return new Response('Request not found', { status: 400 });
716
717 const clientMetadata = JSON.parse(authRequest.client_metadata);
718 const parameters = JSON.parse(authRequest.parameters);
719
720 if (action === 'deny') {
721 await sql`DELETE FROM authorization_requests WHERE id = ${match[1]}`;
722 const errorUrl = new URL(parameters.redirect_uri);
723 errorUrl.searchParams.set('error', 'access_denied');
724 if (parameters.state) errorUrl.searchParams.set('state', parameters.state);
725 errorUrl.searchParams.set('iss', issuer);
726 return Response.redirect(errorUrl.toString(), 302);
727 }
728
729 if (password !== env.PDS_PASSWORD) {
730 return new Response(renderConsentPage({
731 clientName: clientMetadata.client_name || clientId,
732 clientId, scope: parameters.scope || 'atproto', requestUri, error: 'Invalid password'
733 }), { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
734 }
735
736 const code = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
737 const did = await pds.storage.get('did');
738
739 await sql`UPDATE authorization_requests SET code = ${code}, did = ${did} WHERE id = ${match[1]}`;
740
741 const successUrl = new URL(parameters.redirect_uri);
742 successUrl.searchParams.set('code', code);
743 if (parameters.state) successUrl.searchParams.set('state', parameters.state);
744 successUrl.searchParams.set('iss', issuer);
745 return Response.redirect(successUrl.toString(), 302);
746}
747```
748
749**Step 3: Commit**
750
751```bash
752git add src/pds.js
753git commit -m "feat(oauth): implement authorization endpoint with consent UI"
754```
755
756---
757
758## Task 8: Implement Token Endpoint
759
760**Files:**
761- Modify: `src/pds.js`
762
763**Step 1: Add token handler**
764
765```javascript
766if (path === '/oauth/token' && method === 'POST') {
767 return handleToken(request, url, this, env);
768}
769
770/**
771 * Handle token endpoint - exchanges authorization codes for tokens.
772 * Supports authorization_code and refresh_token grant types.
773 * @param {Request} request - The incoming request
774 * @param {URL} url - Parsed request URL
775 * @param {{ storage: DurableObjectStorage }} pds - The PDS instance
776 * @param {{ PDS_PASSWORD: string }} env - Environment variables
777 * @returns {Promise<Response>} JSON response with access_token, token_type, expires_in, refresh_token, scope
778 */
779async function handleToken(request, url, pds, env) {
780 const issuer = `${url.protocol}//${url.host}`;
781
782 const dpopHeader = request.headers.get('DPoP');
783 if (!dpopHeader) return json({ error: 'invalid_dpop_proof', error_description: 'DPoP required' }, 400);
784
785 let dpop;
786 try {
787 dpop = await parseDpopProof(dpopHeader, 'POST', `${issuer}/oauth/token`);
788 } catch (err) {
789 return json({ error: 'invalid_dpop_proof', error_description: err.message }, 400);
790 }
791
792 const body = await request.text();
793 const params = new URLSearchParams(body);
794 const grantType = params.get('grant_type');
795
796 if (grantType === 'authorization_code') {
797 return handleAuthCodeGrant(params, dpop, issuer, pds);
798 } else if (grantType === 'refresh_token') {
799 return handleRefreshGrant(params, dpop, issuer, pds);
800 }
801 return json({ error: 'unsupported_grant_type' }, 400);
802}
803
804/**
805 * Handle authorization_code grant type.
806 * Validates the code, PKCE verifier, and DPoP binding, then issues tokens.
807 * @param {URLSearchParams} params - Token request parameters
808 * @param {DpopProofResult} dpop - Parsed DPoP proof
809 * @param {string} issuer - The PDS issuer URL
810 * @param {{ storage: DurableObjectStorage }} pds - The PDS instance
811 * @returns {Promise<Response>} JSON token response
812 */
813async function handleAuthCodeGrant(params, dpop, issuer, pds) {
814 const code = params.get('code');
815 const redirectUri = params.get('redirect_uri');
816 const clientId = params.get('client_id');
817 const codeVerifier = params.get('code_verifier');
818
819 if (!code || !redirectUri || !clientId || !codeVerifier) {
820 return json({ error: 'invalid_request' }, 400);
821 }
822
823 const sql = createSql(pds.storage);
824 const [authRequest] = await sql`SELECT * FROM authorization_requests WHERE code = ${code}`;
825 if (!authRequest) return json({ error: 'invalid_grant', error_description: 'Invalid code' }, 400);
826 if (authRequest.client_id !== clientId) return json({ error: 'invalid_grant' }, 400);
827 if (authRequest.dpop_jkt !== dpop.jkt) return json({ error: 'invalid_dpop_proof' }, 400);
828
829 const parameters = JSON.parse(authRequest.parameters);
830 if (parameters.redirect_uri !== redirectUri) return json({ error: 'invalid_grant' }, 400);
831
832 // Verify PKCE
833 const challengeHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
834 const computedChallenge = base64UrlEncode(new Uint8Array(challengeHash));
835 if (computedChallenge !== authRequest.code_challenge) {
836 return json({ error: 'invalid_grant', error_description: 'Invalid code_verifier' }, 400);
837 }
838
839 await sql`DELETE FROM authorization_requests WHERE id = ${authRequest.id}`;
840
841 const tokenId = crypto.randomUUID();
842 const refreshToken = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
843 const scope = parameters.scope || 'atproto';
844 const now = new Date();
845 const expiresIn = 3600;
846
847 const accessToken = await createOAuthAccessToken({
848 issuer, subject: authRequest.did, clientId, scope, tokenId, dpopJkt: dpop.jkt, expiresIn
849 }, pds);
850
851 await sql`
852 INSERT INTO tokens (token_id, did, client_id, scope, dpop_jkt, expires_at, refresh_token, created_at, updated_at)
853 VALUES (${tokenId}, ${authRequest.did}, ${clientId}, ${scope}, ${dpop.jkt},
854 ${new Date(now.getTime() + expiresIn * 1000).toISOString()},
855 ${refreshToken}, ${now.toISOString()}, ${now.toISOString()})
856 `;
857
858 return json({ access_token: accessToken, token_type: 'DPoP', expires_in: expiresIn, refresh_token: refreshToken, scope });
859}
860
861/**
862 * @typedef {Object} AccessTokenParams
863 * @property {string} issuer - The PDS issuer URL
864 * @property {string} subject - The DID of the authenticated user
865 * @property {string} clientId - The OAuth client_id
866 * @property {string} scope - The granted scope
867 * @property {string} tokenId - Unique token identifier (jti)
868 * @property {string} dpopJkt - The DPoP key thumbprint for token binding
869 * @property {number} expiresIn - Token lifetime in seconds
870 */
871
872/**
873 * Create a DPoP-bound access token (at+jwt).
874 * @param {AccessTokenParams} params - Token parameters
875 * @param {{ storage: DurableObjectStorage }} pds - The PDS instance
876 * @returns {Promise<string>} The signed JWT access token
877 */
878async function createOAuthAccessToken({ issuer, subject, clientId, scope, tokenId, dpopJkt, expiresIn }, pds) {
879 const now = Math.floor(Date.now() / 1000);
880 const header = { typ: 'at+jwt', alg: 'ES256', kid: 'pds-oauth-key' };
881 const payload = {
882 iss: issuer, sub: subject, aud: issuer, client_id: clientId,
883 scope, jti: tokenId, iat: now, exp: now + expiresIn, cnf: { jkt: dpopJkt }
884 };
885
886 const privateKeyHex = await pds.storage.get('privateKey');
887 const privateKey = await importPrivateKey(hexToBytes(privateKeyHex));
888
889 const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header)));
890 const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload)));
891 const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
892 const sig = await sign(privateKey, sigInput);
893
894 return `${headerB64}.${payloadB64}.${base64UrlEncode(sig)}`;
895}
896```
897
898**Step 2: Commit**
899
900```bash
901git add src/pds.js
902git commit -m "feat(oauth): implement token endpoint"
903```
904
905---
906
907## Task 9: Implement Refresh Token Grant
908
909**Files:**
910- Modify: `src/pds.js`
911
912**Step 1: Add refresh handler**
913
914```javascript
915/**
916 * Handle refresh_token grant type.
917 * Validates the refresh token, DPoP binding, and 24hr lifetime, then rotates tokens.
918 * @param {URLSearchParams} params - Token request parameters
919 * @param {DpopProofResult} dpop - Parsed DPoP proof
920 * @param {string} issuer - The PDS issuer URL
921 * @param {{ storage: DurableObjectStorage }} pds - The PDS instance
922 * @returns {Promise<Response>} JSON token response with new access and refresh tokens
923 */
924async function handleRefreshGrant(params, dpop, issuer, pds) {
925 const refreshToken = params.get('refresh_token');
926 const clientId = params.get('client_id');
927
928 if (!refreshToken || !clientId) return json({ error: 'invalid_request' }, 400);
929
930 const sql = createSql(pds.storage);
931 const [token] = await sql`SELECT * FROM tokens WHERE refresh_token = ${refreshToken}`;
932
933 if (!token) return json({ error: 'invalid_grant', error_description: 'Invalid refresh token' }, 400);
934 if (token.client_id !== clientId) return json({ error: 'invalid_grant' }, 400);
935 if (token.dpop_jkt !== dpop.jkt) return json({ error: 'invalid_dpop_proof' }, 400);
936
937 // Check 24hr lifetime
938 const createdAt = new Date(token.created_at);
939 if (Date.now() - createdAt.getTime() > 24 * 60 * 60 * 1000) {
940 await sql`DELETE FROM tokens WHERE id = ${token.id}`;
941 return json({ error: 'invalid_grant', error_description: 'Refresh token expired' }, 400);
942 }
943
944 const newTokenId = crypto.randomUUID();
945 const newRefreshToken = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
946 const now = new Date();
947 const expiresIn = 3600;
948
949 const accessToken = await createOAuthAccessToken({
950 issuer, subject: token.did, clientId, scope: token.scope,
951 tokenId: newTokenId, dpopJkt: dpop.jkt, expiresIn
952 }, pds);
953
954 await sql`
955 UPDATE tokens SET token_id = ${newTokenId}, refresh_token = ${newRefreshToken},
956 expires_at = ${new Date(now.getTime() + expiresIn * 1000).toISOString()},
957 updated_at = ${now.toISOString()} WHERE id = ${token.id}
958 `;
959
960 return json({ access_token: accessToken, token_type: 'DPoP', expires_in: expiresIn, refresh_token: newRefreshToken, scope: token.scope });
961}
962```
963
964**Step 2: Commit**
965
966```bash
967git add src/pds.js
968git commit -m "feat(oauth): implement refresh token grant"
969```
970
971---
972
973## Task 10: Update requireAuth for DPoP Tokens
974
975**Files:**
976- Modify: `src/pds.js`
977
978**Step 1: Update requireAuth**
979
980```javascript
981/**
982 * @typedef {Object} AuthResult
983 * @property {string} did - The authenticated user's DID
984 * @property {string} [scope] - The granted scope (for OAuth tokens)
985 */
986
987/**
988 * Require authentication for a request.
989 * Supports both legacy Bearer tokens (JWT with symmetric key) and OAuth DPoP tokens.
990 * @param {Request} request - The incoming request
991 * @param {{ JWT_SECRET: string, PDS_PASSWORD: string }} env - Environment variables
992 * @param {{ storage: DurableObjectStorage }} pds - The PDS instance
993 * @returns {Promise<AuthResult>} The authenticated user's DID and scope
994 * @throws {AuthRequiredError} If authentication fails
995 */
996async function requireAuth(request, env, pds) {
997 const authHeader = request.headers.get('Authorization');
998 if (!authHeader) throw new AuthRequiredError('Authorization required');
999
1000 if (authHeader.startsWith('Bearer ')) {
1001 return verifyAccessJwt(authHeader.slice(7), env.JWT_SECRET);
1002 }
1003
1004 if (authHeader.startsWith('DPoP ')) {
1005 return verifyOAuthAccessToken(request, authHeader.slice(5), pds);
1006 }
1007
1008 throw new AuthRequiredError('Invalid authorization type');
1009}
1010
1011/**
1012 * Verify an OAuth DPoP-bound access token.
1013 * Validates the JWT signature, expiration, DPoP binding, and proof.
1014 * @param {Request} request - The incoming request (for DPoP validation)
1015 * @param {string} token - The access token JWT
1016 * @param {{ storage: DurableObjectStorage }} pds - The PDS instance
1017 * @returns {Promise<AuthResult>} The authenticated user's DID and scope
1018 * @throws {AuthRequiredError} If verification fails
1019 */
1020async function verifyOAuthAccessToken(request, token, pds) {
1021 const parts = token.split('.');
1022 if (parts.length !== 3) throw new AuthRequiredError('Invalid token format');
1023
1024 const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0])));
1025 if (header.typ !== 'at+jwt') throw new AuthRequiredError('Invalid token type');
1026
1027 // Verify signature with PDS public key
1028 const publicKeyJwk = await getPublicKeyJwk(pds);
1029 const publicKey = await crypto.subtle.importKey(
1030 'jwk', publicKeyJwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']
1031 );
1032
1033 const signatureInput = new TextEncoder().encode(parts[0] + '.' + parts[1]);
1034 const signature = base64UrlDecode(parts[2]);
1035
1036 const valid = await crypto.subtle.verify(
1037 { name: 'ECDSA', hash: 'SHA-256' }, publicKey,
1038 compactSignatureToDer(signature), signatureInput
1039 );
1040 if (!valid) throw new AuthRequiredError('Invalid token signature');
1041
1042 const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1])));
1043
1044 if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
1045 throw new AuthRequiredError('Token expired');
1046 }
1047
1048 if (!payload.cnf?.jkt) throw new AuthRequiredError('Token missing DPoP binding');
1049
1050 const dpopHeader = request.headers.get('DPoP');
1051 if (!dpopHeader) throw new AuthRequiredError('DPoP proof required');
1052
1053 const url = new URL(request.url);
1054 await parseDpopProof(dpopHeader, request.method, `${url.protocol}//${url.host}${url.pathname}`, payload.cnf.jkt, token);
1055
1056 return { did: payload.sub, scope: payload.scope };
1057}
1058```
1059
1060**Step 2: Commit**
1061
1062```bash
1063git add src/pds.js
1064git commit -m "feat(oauth): update requireAuth to handle DPoP tokens"
1065```
1066
1067---
1068
1069## Task 11: Add Revocation Endpoint
1070
1071**Files:**
1072- Modify: `src/pds.js`
1073
1074**Step 1: Add revoke handler**
1075
1076```javascript
1077if (path === '/oauth/revoke' && method === 'POST') {
1078 return handleRevoke(request, url, this, env);
1079}
1080
1081/**
1082 * Handle token revocation endpoint (RFC 7009).
1083 * Revokes access tokens and refresh tokens by client_id.
1084 * @param {Request} request - The incoming request
1085 * @param {URL} url - Parsed request URL
1086 * @param {{ storage: DurableObjectStorage }} pds - The PDS instance
1087 * @param {{ PDS_PASSWORD: string }} env - Environment variables
1088 * @returns {Promise<Response>} Empty 200 response on success
1089 */
1090async function handleRevoke(request, url, pds, env) {
1091 const body = await request.text();
1092 const params = new URLSearchParams(body);
1093 const token = params.get('token');
1094 const clientId = params.get('client_id');
1095
1096 if (!token || !clientId) return json({ error: 'invalid_request' }, 400);
1097
1098 const sql = createSql(pds.storage);
1099 await sql`
1100 DELETE FROM tokens WHERE client_id = ${clientId}
1101 AND (refresh_token = ${token} OR token_id = ${token})
1102 `;
1103
1104 return new Response(null, { status: 200 });
1105}
1106```
1107
1108**Step 2: Commit**
1109
1110```bash
1111git add src/pds.js
1112git commit -m "feat(oauth): add token revocation endpoint"
1113```
1114
1115---
1116
1117## Task 12: Add OAuth E2E Tests
1118
1119**Files:**
1120- Modify: `test/e2e.sh`
1121
1122**Step 1: Add OAuth tests to e2e.sh**
1123
1124Add after the existing tests:
1125
1126```bash
1127# OAuth tests
1128echo
1129echo "Testing OAuth endpoints..."
1130
1131# Test OAuth Authorization Server Metadata
1132echo "Testing OAuth AS metadata..."
1133AS_METADATA=$(curl -sf "$BASE/.well-known/oauth-authorization-server")
1134echo "$AS_METADATA" | jq -e '.issuer == "'"$BASE"'"' >/dev/null &&
1135 pass "AS metadata: issuer matches base URL" || fail "AS metadata: issuer mismatch"
1136echo "$AS_METADATA" | jq -e '.authorization_endpoint == "'"$BASE"'/oauth/authorize"' >/dev/null &&
1137 pass "AS metadata: authorization_endpoint" || fail "AS metadata: authorization_endpoint"
1138echo "$AS_METADATA" | jq -e '.token_endpoint == "'"$BASE"'/oauth/token"' >/dev/null &&
1139 pass "AS metadata: token_endpoint" || fail "AS metadata: token_endpoint"
1140echo "$AS_METADATA" | jq -e '.pushed_authorization_request_endpoint == "'"$BASE"'/oauth/par"' >/dev/null &&
1141 pass "AS metadata: PAR endpoint" || fail "AS metadata: PAR endpoint"
1142echo "$AS_METADATA" | jq -e '.revocation_endpoint == "'"$BASE"'/oauth/revoke"' >/dev/null &&
1143 pass "AS metadata: revocation_endpoint" || fail "AS metadata: revocation_endpoint"
1144echo "$AS_METADATA" | jq -e '.jwks_uri == "'"$BASE"'/oauth/jwks"' >/dev/null &&
1145 pass "AS metadata: jwks_uri" || fail "AS metadata: jwks_uri"
1146echo "$AS_METADATA" | jq -e '.scopes_supported | contains(["atproto"])' >/dev/null &&
1147 pass "AS metadata: scopes_supported includes atproto" || fail "AS metadata: scopes_supported"
1148echo "$AS_METADATA" | jq -e '.response_types_supported | contains(["code"])' >/dev/null &&
1149 pass "AS metadata: response_types_supported" || fail "AS metadata: response_types_supported"
1150echo "$AS_METADATA" | jq -e '.grant_types_supported | contains(["authorization_code", "refresh_token"])' >/dev/null &&
1151 pass "AS metadata: grant_types_supported" || fail "AS metadata: grant_types_supported"
1152echo "$AS_METADATA" | jq -e '.code_challenge_methods_supported | contains(["S256"])' >/dev/null &&
1153 pass "AS metadata: PKCE S256 supported" || fail "AS metadata: PKCE S256"
1154echo "$AS_METADATA" | jq -e '.dpop_signing_alg_values_supported | contains(["ES256"])' >/dev/null &&
1155 pass "AS metadata: DPoP ES256 supported" || fail "AS metadata: DPoP ES256"
1156echo "$AS_METADATA" | jq -e '.require_pushed_authorization_requests == true' >/dev/null &&
1157 pass "AS metadata: PAR required" || fail "AS metadata: PAR required"
1158echo "$AS_METADATA" | jq -e '.authorization_response_iss_parameter_supported == true' >/dev/null &&
1159 pass "AS metadata: iss parameter supported" || fail "AS metadata: iss parameter"
1160
1161# Test OAuth Protected Resource Metadata
1162echo "Testing OAuth PR metadata..."
1163PR_METADATA=$(curl -sf "$BASE/.well-known/oauth-protected-resource")
1164echo "$PR_METADATA" | jq -e '.resource == "'"$BASE"'"' >/dev/null &&
1165 pass "PR metadata: resource matches base URL" || fail "PR metadata: resource mismatch"
1166echo "$PR_METADATA" | jq -e '.authorization_servers | contains(["'"$BASE"'"])' >/dev/null &&
1167 pass "PR metadata: authorization_servers" || fail "PR metadata: authorization_servers"
1168echo "$PR_METADATA" | jq -e '.scopes_supported | contains(["atproto"])' >/dev/null &&
1169 pass "PR metadata: scopes_supported" || fail "PR metadata: scopes_supported"
1170
1171# Test JWKS endpoint
1172echo "Testing JWKS endpoint..."
1173JWKS=$(curl -sf "$BASE/oauth/jwks")
1174echo "$JWKS" | jq -e '.keys | length > 0' >/dev/null &&
1175 pass "JWKS: has at least one key" || fail "JWKS: no keys"
1176echo "$JWKS" | jq -e '.keys[0].kty == "EC"' >/dev/null &&
1177 pass "JWKS: key is EC type" || fail "JWKS: key type"
1178echo "$JWKS" | jq -e '.keys[0].crv == "P-256"' >/dev/null &&
1179 pass "JWKS: key uses P-256 curve" || fail "JWKS: curve"
1180echo "$JWKS" | jq -e '.keys[0].alg == "ES256"' >/dev/null &&
1181 pass "JWKS: key algorithm is ES256" || fail "JWKS: algorithm"
1182echo "$JWKS" | jq -e '.keys[0].use == "sig"' >/dev/null &&
1183 pass "JWKS: key use is sig" || fail "JWKS: key use"
1184echo "$JWKS" | jq -e '.keys[0].kid == "pds-oauth-key"' >/dev/null &&
1185 pass "JWKS: kid is pds-oauth-key" || fail "JWKS: kid"
1186echo "$JWKS" | jq -e '.keys[0] | has("x") and has("y")' >/dev/null &&
1187 pass "JWKS: has x and y coordinates" || fail "JWKS: coordinates"
1188echo "$JWKS" | jq -e '.keys[0] | has("d") | not' >/dev/null &&
1189 pass "JWKS: does not expose private key (d)" || fail "JWKS: private key exposed!"
1190
1191# Test PAR endpoint error cases
1192echo "Testing PAR error handling..."
1193PAR_NO_DPOP=$(curl -s -w "\n%{http_code}" -X POST "$BASE/oauth/par" \
1194 -H "Content-Type: application/x-www-form-urlencoded" \
1195 -d "client_id=http://localhost:3000&redirect_uri=http://localhost:3000/callback&response_type=code&scope=atproto&code_challenge=test&code_challenge_method=S256")
1196PAR_BODY=$(echo "$PAR_NO_DPOP" | head -n -1)
1197PAR_STATUS=$(echo "$PAR_NO_DPOP" | tail -n 1)
1198[ "$PAR_STATUS" = "400" ] && pass "PAR: rejects missing DPoP (400)" || fail "PAR: should reject missing DPoP"
1199echo "$PAR_BODY" | jq -e '.error == "invalid_dpop_proof"' >/dev/null &&
1200 pass "PAR: error code is invalid_dpop_proof" || fail "PAR: wrong error code"
1201
1202# Test token endpoint error cases
1203echo "Testing token endpoint error handling..."
1204TOKEN_NO_DPOP=$(curl -s -w "\n%{http_code}" -X POST "$BASE/oauth/token" \
1205 -H "Content-Type: application/x-www-form-urlencoded" \
1206 -d "grant_type=authorization_code&code=fake&client_id=http://localhost:3000")
1207TOKEN_BODY=$(echo "$TOKEN_NO_DPOP" | head -n -1)
1208TOKEN_STATUS=$(echo "$TOKEN_NO_DPOP" | tail -n 1)
1209[ "$TOKEN_STATUS" = "400" ] && pass "Token: rejects missing DPoP (400)" || fail "Token: should reject missing DPoP"
1210echo "$TOKEN_BODY" | jq -e '.error == "invalid_dpop_proof"' >/dev/null &&
1211 pass "Token: error code is invalid_dpop_proof" || fail "Token: wrong error code"
1212
1213# Test revoke endpoint (should accept without valid token - RFC 7009 says always 200)
1214echo "Testing revoke endpoint..."
1215REVOKE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/oauth/revoke" \
1216 -H "Content-Type: application/x-www-form-urlencoded" \
1217 -d "token=nonexistent&client_id=http://localhost:3000")
1218[ "$REVOKE_STATUS" = "200" ] && pass "Revoke: returns 200 even for invalid token" || fail "Revoke: should always return 200"
1219
1220echo
1221echo "All OAuth endpoint tests passed!"
1222```
1223
1224**Step 2: Commit**
1225
1226```bash
1227git add test/e2e.sh
1228git commit -m "test(oauth): add comprehensive OAuth e2e tests"
1229```
1230
1231---
1232
1233## Task 13: Run Typecheck and Fix Any Errors
1234
1235**Files:**
1236- Modify: `src/pds.js` (if needed)
1237
1238**Step 1: Run TypeScript type checking**
1239
1240```bash
1241npm run typecheck
1242```
1243
1244Expect: No type errors. If there are errors, fix them before continuing.
1245
1246**Step 2: Run unit tests**
1247
1248```bash
1249npm test
1250```
1251
1252Expect: All tests pass.
1253
1254**Step 3: Run e2e tests**
1255
1256Start wrangler dev in one terminal, then run:
1257
1258```bash
1259./test/e2e.sh
1260```
1261
1262Expect: All tests pass.
1263
1264**Step 4: Final commit (if any fixes were needed)**
1265
1266```bash
1267git add src/pds.js
1268git commit -m "fix(oauth): address typecheck errors"
1269```
1270
1271---
1272
1273## Summary
1274
1275This plan implements AT Protocol OAuth with:
1276- PAR (Pushed Authorization Requests)
1277- DPoP (Demonstration of Proof-of-Possession)
1278- PKCE (Proof Key for Code Exchange)
1279- Authorization code flow with consent UI
1280- Token refresh and revocation
1281- Backward compatibility with existing Bearer tokens
1282
1283All implemented with zero external dependencies using Web Crypto APIs.