# OAuth Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add full AT Protocol OAuth support (PAR, DPoP, PKCE, authorization code flow) to pds.js while maintaining zero external dependencies. **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. **Tech Stack:** JavaScript (Cloudflare Workers), SQLite (Durable Objects), Web Crypto API, P-256/ES256 signatures. --- ## Task 1: Add OAuth Database Tables **Files:** - Modify: `src/pds.js` **Step 1: Add tables to initializeDatabase** In `src/pds.js`, add to the `initializeDatabase` function after existing table creation: ```javascript // OAuth authorization requests (from PAR) await sql` CREATE TABLE IF NOT EXISTS authorization_requests ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, client_metadata TEXT NOT NULL, parameters TEXT NOT NULL, code TEXT, code_challenge TEXT, code_challenge_method TEXT, dpop_jkt TEXT, did TEXT, expires_at TEXT NOT NULL, created_at TEXT NOT NULL ) `; await sql` CREATE INDEX IF NOT EXISTS idx_authorization_requests_code ON authorization_requests(code) WHERE code IS NOT NULL `; // OAuth tokens await sql` CREATE TABLE IF NOT EXISTS tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, token_id TEXT UNIQUE NOT NULL, did TEXT NOT NULL, client_id TEXT NOT NULL, scope TEXT, dpop_jkt TEXT, expires_at TEXT NOT NULL, refresh_token TEXT UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) `; await sql` CREATE INDEX IF NOT EXISTS idx_tokens_did ON tokens(did) `; ``` **Step 2: Commit** ```bash git add src/pds.js git commit -m "feat(oauth): add authorization_requests and tokens tables" ``` --- ## Task 2: Implement JWK Thumbprint **Files:** - Modify: `src/pds.js` - Test: `test/pds.test.js` **Step 1: Add unit test** Add to `test/pds.test.js` imports and test: ```javascript import { // ... existing imports ... computeJwkThumbprint, } from '../src/pds.js'; describe('JWK Thumbprint', () => { test('computes deterministic thumbprint for EC key', async () => { // Test vector: known JWK and its expected thumbprint const jwk = { kty: 'EC', crv: 'P-256', x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ' }; const jkt1 = await computeJwkThumbprint(jwk); const jkt2 = await computeJwkThumbprint(jwk); // Thumbprint must be deterministic assert.strictEqual(jkt1, jkt2); // Must be base64url-encoded SHA-256 (43 chars) assert.strictEqual(jkt1.length, 43); // Must only contain base64url characters assert.match(jkt1, /^[A-Za-z0-9_-]+$/); }); test('produces different thumbprints for different keys', async () => { const jwk1 = { kty: 'EC', crv: 'P-256', x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ' }; const jwk2 = { kty: 'EC', crv: 'P-256', x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0' }; const jkt1 = await computeJwkThumbprint(jwk1); const jkt2 = await computeJwkThumbprint(jwk2); assert.notStrictEqual(jkt1, jkt2); }); }); ``` **Step 2: Implement and export** Add to `src/pds.js`: ```javascript /** * Compute JWK thumbprint (SHA-256) per RFC 7638. * Creates a canonical JSON representation of EC key required members * and returns the base64url-encoded SHA-256 hash. * @param {{ kty: string, crv: string, x: string, y: string }} jwk - The EC public key in JWK format * @returns {Promise} The base64url-encoded thumbprint */ export async function computeJwkThumbprint(jwk) { // RFC 7638: members must be in lexicographic order const thumbprintInput = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }); const hash = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(thumbprintInput) ); return base64UrlEncode(new Uint8Array(hash)); } ``` **Step 3: Run tests and commit** ```bash npm test git add src/pds.js test/pds.test.js git commit -m "feat(oauth): implement JWK thumbprint computation" ``` --- ## Task 3: Implement Client Metadata Validation **Files:** - Modify: `src/pds.js` - Test: `test/pds.test.js` **Step 1: Add unit tests** ```javascript import { // ... existing imports ... isLoopbackClient, getLoopbackClientMetadata, validateClientMetadata, } from '../src/pds.js'; describe('Client Metadata', () => { test('isLoopbackClient detects localhost', () => { assert.strictEqual(isLoopbackClient('http://localhost:8080'), true); assert.strictEqual(isLoopbackClient('http://127.0.0.1:3000'), true); assert.strictEqual(isLoopbackClient('https://example.com'), false); }); test('getLoopbackClientMetadata returns permissive defaults', () => { const metadata = getLoopbackClientMetadata('http://localhost:8080'); assert.strictEqual(metadata.client_id, 'http://localhost:8080'); assert.ok(metadata.grant_types.includes('authorization_code')); assert.strictEqual(metadata.dpop_bound_access_tokens, true); }); test('validateClientMetadata rejects mismatched client_id', () => { const metadata = { client_id: 'https://other.com/metadata.json', redirect_uris: ['https://example.com/callback'], grant_types: ['authorization_code'], response_types: ['code'] }; assert.throws( () => validateClientMetadata(metadata, 'https://example.com/metadata.json'), /client_id mismatch/ ); }); }); ``` **Step 2: Implement functions** ```javascript /** * Check if a client_id represents a loopback client (localhost development). * Loopback clients are allowed without pre-registration per AT Protocol OAuth spec. * @param {string} clientId - The client_id to check * @returns {boolean} True if the client_id is a loopback address */ export function isLoopbackClient(clientId) { try { const url = new URL(clientId); const host = url.hostname.toLowerCase(); return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'; } catch { return false; } } /** * @typedef {Object} ClientMetadata * @property {string} client_id - The client identifier (must match the URL used to fetch metadata) * @property {string} [client_name] - Human-readable client name * @property {string[]} redirect_uris - Allowed redirect URIs * @property {string[]} grant_types - Supported grant types * @property {string[]} response_types - Supported response types * @property {string} [token_endpoint_auth_method] - Token endpoint auth method * @property {boolean} [dpop_bound_access_tokens] - Whether client requires DPoP-bound tokens * @property {string} [scope] - Default scope */ /** * Generate permissive client metadata for a loopback client. * @param {string} clientId - The loopback client_id * @returns {ClientMetadata} Generated client metadata */ export function getLoopbackClientMetadata(clientId) { return { client_id: clientId, client_name: 'Loopback Client', redirect_uris: [clientId], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', dpop_bound_access_tokens: true, scope: 'atproto' }; } /** * Validate client metadata against AT Protocol OAuth requirements. * @param {ClientMetadata} metadata - The client metadata to validate * @param {string} expectedClientId - The expected client_id (the URL used to fetch metadata) * @throws {Error} If validation fails */ export function validateClientMetadata(metadata, expectedClientId) { if (!metadata.client_id) throw new Error('client_id is required'); if (metadata.client_id !== expectedClientId) throw new Error('client_id mismatch'); if (!Array.isArray(metadata.redirect_uris) || metadata.redirect_uris.length === 0) { throw new Error('redirect_uris is required'); } if (!metadata.grant_types?.includes('authorization_code')) { throw new Error('grant_types must include authorization_code'); } } /** @type {Map} */ const clientMetadataCache = new Map(); /** * Fetch and validate client metadata from a client_id URL. * Caches results for 10 minutes. Loopback clients return synthetic metadata. * @param {string} clientId - The client_id (URL to fetch metadata from) * @returns {Promise} The validated client metadata * @throws {Error} If fetching or validation fails */ async function getClientMetadata(clientId) { const cached = clientMetadataCache.get(clientId); if (cached && Date.now() < cached.expiresAt) return cached.metadata; if (isLoopbackClient(clientId)) { const metadata = getLoopbackClientMetadata(clientId); clientMetadataCache.set(clientId, { metadata, expiresAt: Date.now() + 600000 }); return metadata; } const response = await fetch(clientId, { headers: { 'Accept': 'application/json' } }); if (!response.ok) throw new Error(`Failed to fetch client metadata: ${response.status}`); const metadata = await response.json(); validateClientMetadata(metadata, clientId); clientMetadataCache.set(clientId, { metadata, expiresAt: Date.now() + 600000 }); return metadata; } ``` **Step 3: Run tests and commit** ```bash npm test git add src/pds.js test/pds.test.js git commit -m "feat(oauth): implement client metadata fetching and validation" ``` --- ## Task 4: Implement DPoP Proof Parsing **Files:** - Modify: `src/pds.js` **Step 1: Implement parseDpopProof** ```javascript /** * @typedef {Object} DpopProofResult * @property {string} jkt - The JWK thumbprint of the DPoP key * @property {string} jti - The unique identifier from the DPoP proof * @property {{ kty: string, crv: string, x: string, y: string }} jwk - The public key from the proof */ /** * Parse and validate a DPoP proof JWT. * Verifies the signature, checks claims (htm, htu, iat, jti), and optionally * validates key binding (expectedJkt) and access token hash (ath). * @param {string} proof - The DPoP proof JWT * @param {string} method - The expected HTTP method (htm claim) * @param {string} url - The expected request URL (htu claim) * @param {string|null} [expectedJkt=null] - If provided, verify the key matches this thumbprint * @param {string|null} [accessToken=null] - If provided, verify the ath claim matches this token's hash * @returns {Promise} The parsed proof with jkt, jti, and jwk * @throws {Error} If validation fails */ async function parseDpopProof(proof, method, url, expectedJkt = null, accessToken = null) { const parts = proof.split('.'); if (parts.length !== 3) throw new Error('Invalid DPoP proof format'); const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))); const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))); if (header.typ !== 'dpop+jwt') throw new Error('DPoP proof must have typ dpop+jwt'); if (header.alg !== 'ES256') throw new Error('DPoP proof must use ES256'); if (!header.jwk || header.jwk.kty !== 'EC') throw new Error('DPoP proof must contain EC key'); // Verify signature const publicKey = await crypto.subtle.importKey( 'jwk', header.jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'] ); const signatureInput = new TextEncoder().encode(parts[0] + '.' + parts[1]); const signature = base64UrlDecode(parts[2]); const derSignature = compactSignatureToDer(signature); const valid = await crypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, publicKey, derSignature, signatureInput ); if (!valid) throw new Error('DPoP proof signature invalid'); // Validate claims if (payload.htm !== method) throw new Error('DPoP htm mismatch'); const normalizeUrl = (u) => u.replace(/\/$/, '').split('?')[0].toLowerCase(); if (normalizeUrl(payload.htu) !== normalizeUrl(url)) throw new Error('DPoP htu mismatch'); const now = Math.floor(Date.now() / 1000); if (!payload.iat || payload.iat > now + 60 || payload.iat < now - 300) { throw new Error('DPoP proof expired or invalid iat'); } if (!payload.jti) throw new Error('DPoP proof missing jti'); const jkt = await computeJwkThumbprint(header.jwk); if (expectedJkt && jkt !== expectedJkt) throw new Error('DPoP key mismatch'); if (accessToken) { const tokenHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken)); const expectedAth = base64UrlEncode(new Uint8Array(tokenHash)); if (payload.ath !== expectedAth) throw new Error('DPoP ath mismatch'); } return { jkt, jti: payload.jti, jwk: header.jwk }; } /** * Convert a compact (r||s) ECDSA signature to DER format for Web Crypto API. * @param {Uint8Array} compact - The 64-byte compact signature (32 bytes r + 32 bytes s) * @returns {Uint8Array} The DER-encoded signature */ function compactSignatureToDer(compact) { const r = compact.slice(0, 32); const s = compact.slice(32, 64); /** * @param {Uint8Array} bytes * @returns {Uint8Array} */ function encodeInt(bytes) { let i = 0; while (i < bytes.length - 1 && bytes[i] === 0 && !(bytes[i + 1] & 0x80)) i++; const trimmed = bytes.slice(i); if (trimmed[0] & 0x80) return new Uint8Array([0x02, trimmed.length + 1, 0, ...trimmed]); return new Uint8Array([0x02, trimmed.length, ...trimmed]); } const rDer = encodeInt(r); const sDer = encodeInt(s); return new Uint8Array([0x30, rDer.length + sDer.length, ...rDer, ...sDer]); } ``` **Step 2: Commit** ```bash git add src/pds.js git commit -m "feat(oauth): implement DPoP proof parsing" ``` --- ## Task 5: Add OAuth Discovery Endpoints **Files:** - Modify: `src/pds.js` **Step 1: Add endpoints to handleRequest** ```javascript // OAuth Authorization Server Metadata if (path === '/.well-known/oauth-authorization-server' && method === 'GET') { const issuer = `${url.protocol}//${url.host}`; return json({ issuer, authorization_endpoint: `${issuer}/oauth/authorize`, token_endpoint: `${issuer}/oauth/token`, revocation_endpoint: `${issuer}/oauth/revoke`, pushed_authorization_request_endpoint: `${issuer}/oauth/par`, jwks_uri: `${issuer}/oauth/jwks`, scopes_supported: ['atproto'], response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'], dpop_signing_alg_values_supported: ['ES256'], require_pushed_authorization_requests: true, authorization_response_iss_parameter_supported: true }); } // OAuth Protected Resource Metadata if (path === '/.well-known/oauth-protected-resource' && method === 'GET') { const resource = `${url.protocol}//${url.host}`; return json({ resource, authorization_servers: [resource], bearer_methods_supported: ['header'], scopes_supported: ['atproto'] }); } // JWKS endpoint if (path === '/oauth/jwks' && method === 'GET') { const publicKeyJwk = await getPublicKeyJwk(this); return json({ keys: [{ ...publicKeyJwk, kid: 'pds-oauth-key', use: 'sig', alg: 'ES256' }] }); } ``` **Step 2: Add getPublicKeyJwk helper** ```javascript /** * Get the PDS signing key as a public JWK. * Exports only the public components (kty, crv, x, y) for use in JWKS. * @param {{ storage: DurableObjectStorage }} pds - The PDS instance * @returns {Promise<{ kty: string, crv: string, x: string, y: string }>} The public key in JWK format * @throws {Error} If the PDS is not initialized */ async function getPublicKeyJwk(pds) { const privateKeyHex = await pds.storage.get('privateKey'); if (!privateKeyHex) throw new Error('PDS not initialized'); const privateKeyBytes = hexToBytes(privateKeyHex); const privateKey = await crypto.subtle.importKey( 'pkcs8', privateKeyBytes, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'] ); const jwk = await crypto.subtle.exportKey('jwk', privateKey); return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; } ``` **Step 3: Commit** ```bash git add src/pds.js git commit -m "feat(oauth): add discovery endpoints" ``` --- ## Task 6: Implement PAR Endpoint **Files:** - Modify: `src/pds.js` **Step 1: Add PAR handler** ```javascript if (path === '/oauth/par' && method === 'POST') { return handlePar(request, url, this, env); } /** * Handle Pushed Authorization Request (PAR) endpoint. * Validates DPoP proof, client metadata, PKCE parameters, and stores the authorization request. * @param {Request} request - The incoming request * @param {URL} url - Parsed request URL * @param {{ storage: DurableObjectStorage }} pds - The PDS instance * @param {{ PDS_PASSWORD: string }} env - Environment variables * @returns {Promise} JSON response with request_uri and expires_in */ async function handlePar(request, url, pds, env) { const issuer = `${url.protocol}//${url.host}`; const dpopHeader = request.headers.get('DPoP'); if (!dpopHeader) { return json({ error: 'invalid_dpop_proof', error_description: 'DPoP proof required' }, 400); } let dpop; try { dpop = await parseDpopProof(dpopHeader, 'POST', `${issuer}/oauth/par`); } catch (err) { return json({ error: 'invalid_dpop_proof', error_description: err.message }, 400); } const body = await request.text(); const params = new URLSearchParams(body); const clientId = params.get('client_id'); const redirectUri = params.get('redirect_uri'); const responseType = params.get('response_type'); const scope = params.get('scope'); const state = params.get('state'); const codeChallenge = params.get('code_challenge'); const codeChallengeMethod = params.get('code_challenge_method'); if (!clientId) return json({ error: 'invalid_request', error_description: 'client_id required' }, 400); if (!redirectUri) return json({ error: 'invalid_request', error_description: 'redirect_uri required' }, 400); if (responseType !== 'code') return json({ error: 'unsupported_response_type' }, 400); if (!codeChallenge || codeChallengeMethod !== 'S256') { return json({ error: 'invalid_request', error_description: 'PKCE with S256 required' }, 400); } let clientMetadata; try { clientMetadata = await getClientMetadata(clientId); } catch (err) { return json({ error: 'invalid_client', error_description: err.message }, 400); } const requestId = crypto.randomUUID(); const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; const expiresIn = 600; const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); const sql = createSql(pds.storage); await sql` INSERT INTO authorization_requests ( id, client_id, client_metadata, parameters, code_challenge, code_challenge_method, dpop_jkt, expires_at, created_at ) VALUES ( ${requestId}, ${clientId}, ${JSON.stringify(clientMetadata)}, ${JSON.stringify({ redirect_uri: redirectUri, scope, state })}, ${codeChallenge}, ${codeChallengeMethod}, ${dpop.jkt}, ${expiresAt}, ${new Date().toISOString()} ) `; return json({ request_uri: requestUri, expires_in: expiresIn }); } ``` **Step 2: Commit** ```bash git add src/pds.js git commit -m "feat(oauth): implement PAR endpoint" ``` --- ## Task 7: Implement Authorization Endpoint **Files:** - Modify: `src/pds.js` **Step 1: Add GET handler (consent UI)** ```javascript if (path === '/oauth/authorize' && method === 'GET') { return handleAuthorizeGet(request, url, this, env); } /** * Handle GET /oauth/authorize - displays the consent UI. * Validates the request_uri from PAR and renders a login/consent form. * @param {Request} request - The incoming request * @param {URL} url - Parsed request URL * @param {{ storage: DurableObjectStorage }} pds - The PDS instance * @param {{ PDS_PASSWORD: string }} env - Environment variables * @returns {Promise} HTML consent page */ async function handleAuthorizeGet(request, url, pds, env) { const requestUri = url.searchParams.get('request_uri'); const clientId = url.searchParams.get('client_id'); if (!requestUri || !clientId) return new Response('Missing parameters', { status: 400 }); const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); if (!match) return new Response('Invalid request_uri', { status: 400 }); const sql = createSql(pds.storage); const [authRequest] = await sql` SELECT * FROM authorization_requests WHERE id = ${match[1]} AND client_id = ${clientId} `; if (!authRequest) return new Response('Request not found', { status: 400 }); if (new Date(authRequest.expires_at) < new Date()) return new Response('Request expired', { status: 400 }); if (authRequest.code) return new Response('Request already used', { status: 400 }); const clientMetadata = JSON.parse(authRequest.client_metadata); const parameters = JSON.parse(authRequest.parameters); return new Response(renderConsentPage({ clientName: clientMetadata.client_name || clientId, clientId, scope: parameters.scope || 'atproto', requestUri }), { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } /** * Render the OAuth consent page HTML. * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params * @returns {string} HTML page content */ function renderConsentPage({ clientName, clientId, scope, requestUri, error = '' }) { /** @param {string} s */ const escHtml = s => s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); return ` Authorize

Sign in to authorize

${escHtml(clientName)} wants to access your account.

Scope: ${escHtml(scope)}

${error ? `

${escHtml(error)}

` : ''}
`; } ``` **Step 2: Add POST handler (approval)** ```javascript if (path === '/oauth/authorize' && method === 'POST') { return handleAuthorizePost(request, url, this, env); } /** * Handle POST /oauth/authorize - processes user approval/denial. * Validates password, generates authorization code on approval, redirects to client. * @param {Request} request - The incoming request * @param {URL} url - Parsed request URL * @param {{ storage: DurableObjectStorage }} pds - The PDS instance * @param {{ PDS_PASSWORD: string }} env - Environment variables * @returns {Promise} Redirect to client redirect_uri with code or error */ async function handleAuthorizePost(request, url, pds, env) { const issuer = `${url.protocol}//${url.host}`; const body = await request.text(); const params = new URLSearchParams(body); const requestUri = params.get('request_uri'); const clientId = params.get('client_id'); const password = params.get('password'); const action = params.get('action'); const match = requestUri?.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); if (!match) return new Response('Invalid request_uri', { status: 400 }); const sql = createSql(pds.storage); const [authRequest] = await sql` SELECT * FROM authorization_requests WHERE id = ${match[1]} AND client_id = ${clientId} `; if (!authRequest) return new Response('Request not found', { status: 400 }); const clientMetadata = JSON.parse(authRequest.client_metadata); const parameters = JSON.parse(authRequest.parameters); if (action === 'deny') { await sql`DELETE FROM authorization_requests WHERE id = ${match[1]}`; const errorUrl = new URL(parameters.redirect_uri); errorUrl.searchParams.set('error', 'access_denied'); if (parameters.state) errorUrl.searchParams.set('state', parameters.state); errorUrl.searchParams.set('iss', issuer); return Response.redirect(errorUrl.toString(), 302); } if (password !== env.PDS_PASSWORD) { return new Response(renderConsentPage({ clientName: clientMetadata.client_name || clientId, clientId, scope: parameters.scope || 'atproto', requestUri, error: 'Invalid password' }), { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } const code = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); const did = await pds.storage.get('did'); await sql`UPDATE authorization_requests SET code = ${code}, did = ${did} WHERE id = ${match[1]}`; const successUrl = new URL(parameters.redirect_uri); successUrl.searchParams.set('code', code); if (parameters.state) successUrl.searchParams.set('state', parameters.state); successUrl.searchParams.set('iss', issuer); return Response.redirect(successUrl.toString(), 302); } ``` **Step 3: Commit** ```bash git add src/pds.js git commit -m "feat(oauth): implement authorization endpoint with consent UI" ``` --- ## Task 8: Implement Token Endpoint **Files:** - Modify: `src/pds.js` **Step 1: Add token handler** ```javascript if (path === '/oauth/token' && method === 'POST') { return handleToken(request, url, this, env); } /** * Handle token endpoint - exchanges authorization codes for tokens. * Supports authorization_code and refresh_token grant types. * @param {Request} request - The incoming request * @param {URL} url - Parsed request URL * @param {{ storage: DurableObjectStorage }} pds - The PDS instance * @param {{ PDS_PASSWORD: string }} env - Environment variables * @returns {Promise} JSON response with access_token, token_type, expires_in, refresh_token, scope */ async function handleToken(request, url, pds, env) { const issuer = `${url.protocol}//${url.host}`; const dpopHeader = request.headers.get('DPoP'); if (!dpopHeader) return json({ error: 'invalid_dpop_proof', error_description: 'DPoP required' }, 400); let dpop; try { dpop = await parseDpopProof(dpopHeader, 'POST', `${issuer}/oauth/token`); } catch (err) { return json({ error: 'invalid_dpop_proof', error_description: err.message }, 400); } const body = await request.text(); const params = new URLSearchParams(body); const grantType = params.get('grant_type'); if (grantType === 'authorization_code') { return handleAuthCodeGrant(params, dpop, issuer, pds); } else if (grantType === 'refresh_token') { return handleRefreshGrant(params, dpop, issuer, pds); } return json({ error: 'unsupported_grant_type' }, 400); } /** * Handle authorization_code grant type. * Validates the code, PKCE verifier, and DPoP binding, then issues tokens. * @param {URLSearchParams} params - Token request parameters * @param {DpopProofResult} dpop - Parsed DPoP proof * @param {string} issuer - The PDS issuer URL * @param {{ storage: DurableObjectStorage }} pds - The PDS instance * @returns {Promise} JSON token response */ async function handleAuthCodeGrant(params, dpop, issuer, pds) { const code = params.get('code'); const redirectUri = params.get('redirect_uri'); const clientId = params.get('client_id'); const codeVerifier = params.get('code_verifier'); if (!code || !redirectUri || !clientId || !codeVerifier) { return json({ error: 'invalid_request' }, 400); } const sql = createSql(pds.storage); const [authRequest] = await sql`SELECT * FROM authorization_requests WHERE code = ${code}`; if (!authRequest) return json({ error: 'invalid_grant', error_description: 'Invalid code' }, 400); if (authRequest.client_id !== clientId) return json({ error: 'invalid_grant' }, 400); if (authRequest.dpop_jkt !== dpop.jkt) return json({ error: 'invalid_dpop_proof' }, 400); const parameters = JSON.parse(authRequest.parameters); if (parameters.redirect_uri !== redirectUri) return json({ error: 'invalid_grant' }, 400); // Verify PKCE const challengeHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)); const computedChallenge = base64UrlEncode(new Uint8Array(challengeHash)); if (computedChallenge !== authRequest.code_challenge) { return json({ error: 'invalid_grant', error_description: 'Invalid code_verifier' }, 400); } await sql`DELETE FROM authorization_requests WHERE id = ${authRequest.id}`; const tokenId = crypto.randomUUID(); const refreshToken = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); const scope = parameters.scope || 'atproto'; const now = new Date(); const expiresIn = 3600; const accessToken = await createOAuthAccessToken({ issuer, subject: authRequest.did, clientId, scope, tokenId, dpopJkt: dpop.jkt, expiresIn }, pds); await sql` INSERT INTO tokens (token_id, did, client_id, scope, dpop_jkt, expires_at, refresh_token, created_at, updated_at) VALUES (${tokenId}, ${authRequest.did}, ${clientId}, ${scope}, ${dpop.jkt}, ${new Date(now.getTime() + expiresIn * 1000).toISOString()}, ${refreshToken}, ${now.toISOString()}, ${now.toISOString()}) `; return json({ access_token: accessToken, token_type: 'DPoP', expires_in: expiresIn, refresh_token: refreshToken, scope }); } /** * @typedef {Object} AccessTokenParams * @property {string} issuer - The PDS issuer URL * @property {string} subject - The DID of the authenticated user * @property {string} clientId - The OAuth client_id * @property {string} scope - The granted scope * @property {string} tokenId - Unique token identifier (jti) * @property {string} dpopJkt - The DPoP key thumbprint for token binding * @property {number} expiresIn - Token lifetime in seconds */ /** * Create a DPoP-bound access token (at+jwt). * @param {AccessTokenParams} params - Token parameters * @param {{ storage: DurableObjectStorage }} pds - The PDS instance * @returns {Promise} The signed JWT access token */ async function createOAuthAccessToken({ issuer, subject, clientId, scope, tokenId, dpopJkt, expiresIn }, pds) { const now = Math.floor(Date.now() / 1000); const header = { typ: 'at+jwt', alg: 'ES256', kid: 'pds-oauth-key' }; const payload = { iss: issuer, sub: subject, aud: issuer, client_id: clientId, scope, jti: tokenId, iat: now, exp: now + expiresIn, cnf: { jkt: dpopJkt } }; const privateKeyHex = await pds.storage.get('privateKey'); const privateKey = await importPrivateKey(hexToBytes(privateKeyHex)); const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))); const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))); const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); const sig = await sign(privateKey, sigInput); return `${headerB64}.${payloadB64}.${base64UrlEncode(sig)}`; } ``` **Step 2: Commit** ```bash git add src/pds.js git commit -m "feat(oauth): implement token endpoint" ``` --- ## Task 9: Implement Refresh Token Grant **Files:** - Modify: `src/pds.js` **Step 1: Add refresh handler** ```javascript /** * Handle refresh_token grant type. * Validates the refresh token, DPoP binding, and 24hr lifetime, then rotates tokens. * @param {URLSearchParams} params - Token request parameters * @param {DpopProofResult} dpop - Parsed DPoP proof * @param {string} issuer - The PDS issuer URL * @param {{ storage: DurableObjectStorage }} pds - The PDS instance * @returns {Promise} JSON token response with new access and refresh tokens */ async function handleRefreshGrant(params, dpop, issuer, pds) { const refreshToken = params.get('refresh_token'); const clientId = params.get('client_id'); if (!refreshToken || !clientId) return json({ error: 'invalid_request' }, 400); const sql = createSql(pds.storage); const [token] = await sql`SELECT * FROM tokens WHERE refresh_token = ${refreshToken}`; if (!token) return json({ error: 'invalid_grant', error_description: 'Invalid refresh token' }, 400); if (token.client_id !== clientId) return json({ error: 'invalid_grant' }, 400); if (token.dpop_jkt !== dpop.jkt) return json({ error: 'invalid_dpop_proof' }, 400); // Check 24hr lifetime const createdAt = new Date(token.created_at); if (Date.now() - createdAt.getTime() > 24 * 60 * 60 * 1000) { await sql`DELETE FROM tokens WHERE id = ${token.id}`; return json({ error: 'invalid_grant', error_description: 'Refresh token expired' }, 400); } const newTokenId = crypto.randomUUID(); const newRefreshToken = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); const now = new Date(); const expiresIn = 3600; const accessToken = await createOAuthAccessToken({ issuer, subject: token.did, clientId, scope: token.scope, tokenId: newTokenId, dpopJkt: dpop.jkt, expiresIn }, pds); await sql` UPDATE tokens SET token_id = ${newTokenId}, refresh_token = ${newRefreshToken}, expires_at = ${new Date(now.getTime() + expiresIn * 1000).toISOString()}, updated_at = ${now.toISOString()} WHERE id = ${token.id} `; return json({ access_token: accessToken, token_type: 'DPoP', expires_in: expiresIn, refresh_token: newRefreshToken, scope: token.scope }); } ``` **Step 2: Commit** ```bash git add src/pds.js git commit -m "feat(oauth): implement refresh token grant" ``` --- ## Task 10: Update requireAuth for DPoP Tokens **Files:** - Modify: `src/pds.js` **Step 1: Update requireAuth** ```javascript /** * @typedef {Object} AuthResult * @property {string} did - The authenticated user's DID * @property {string} [scope] - The granted scope (for OAuth tokens) */ /** * Require authentication for a request. * Supports both legacy Bearer tokens (JWT with symmetric key) and OAuth DPoP tokens. * @param {Request} request - The incoming request * @param {{ JWT_SECRET: string, PDS_PASSWORD: string }} env - Environment variables * @param {{ storage: DurableObjectStorage }} pds - The PDS instance * @returns {Promise} The authenticated user's DID and scope * @throws {AuthRequiredError} If authentication fails */ async function requireAuth(request, env, pds) { const authHeader = request.headers.get('Authorization'); if (!authHeader) throw new AuthRequiredError('Authorization required'); if (authHeader.startsWith('Bearer ')) { return verifyAccessJwt(authHeader.slice(7), env.JWT_SECRET); } if (authHeader.startsWith('DPoP ')) { return verifyOAuthAccessToken(request, authHeader.slice(5), pds); } throw new AuthRequiredError('Invalid authorization type'); } /** * Verify an OAuth DPoP-bound access token. * Validates the JWT signature, expiration, DPoP binding, and proof. * @param {Request} request - The incoming request (for DPoP validation) * @param {string} token - The access token JWT * @param {{ storage: DurableObjectStorage }} pds - The PDS instance * @returns {Promise} The authenticated user's DID and scope * @throws {AuthRequiredError} If verification fails */ async function verifyOAuthAccessToken(request, token, pds) { const parts = token.split('.'); if (parts.length !== 3) throw new AuthRequiredError('Invalid token format'); const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))); if (header.typ !== 'at+jwt') throw new AuthRequiredError('Invalid token type'); // Verify signature with PDS public key const publicKeyJwk = await getPublicKeyJwk(pds); const publicKey = await crypto.subtle.importKey( 'jwk', publicKeyJwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'] ); const signatureInput = new TextEncoder().encode(parts[0] + '.' + parts[1]); const signature = base64UrlDecode(parts[2]); const valid = await crypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, publicKey, compactSignatureToDer(signature), signatureInput ); if (!valid) throw new AuthRequiredError('Invalid token signature'); const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))); if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { throw new AuthRequiredError('Token expired'); } if (!payload.cnf?.jkt) throw new AuthRequiredError('Token missing DPoP binding'); const dpopHeader = request.headers.get('DPoP'); if (!dpopHeader) throw new AuthRequiredError('DPoP proof required'); const url = new URL(request.url); await parseDpopProof(dpopHeader, request.method, `${url.protocol}//${url.host}${url.pathname}`, payload.cnf.jkt, token); return { did: payload.sub, scope: payload.scope }; } ``` **Step 2: Commit** ```bash git add src/pds.js git commit -m "feat(oauth): update requireAuth to handle DPoP tokens" ``` --- ## Task 11: Add Revocation Endpoint **Files:** - Modify: `src/pds.js` **Step 1: Add revoke handler** ```javascript if (path === '/oauth/revoke' && method === 'POST') { return handleRevoke(request, url, this, env); } /** * Handle token revocation endpoint (RFC 7009). * Revokes access tokens and refresh tokens by client_id. * @param {Request} request - The incoming request * @param {URL} url - Parsed request URL * @param {{ storage: DurableObjectStorage }} pds - The PDS instance * @param {{ PDS_PASSWORD: string }} env - Environment variables * @returns {Promise} Empty 200 response on success */ async function handleRevoke(request, url, pds, env) { const body = await request.text(); const params = new URLSearchParams(body); const token = params.get('token'); const clientId = params.get('client_id'); if (!token || !clientId) return json({ error: 'invalid_request' }, 400); const sql = createSql(pds.storage); await sql` DELETE FROM tokens WHERE client_id = ${clientId} AND (refresh_token = ${token} OR token_id = ${token}) `; return new Response(null, { status: 200 }); } ``` **Step 2: Commit** ```bash git add src/pds.js git commit -m "feat(oauth): add token revocation endpoint" ``` --- ## Task 12: Add OAuth E2E Tests **Files:** - Modify: `test/e2e.sh` **Step 1: Add OAuth tests to e2e.sh** Add after the existing tests: ```bash # OAuth tests echo echo "Testing OAuth endpoints..." # Test OAuth Authorization Server Metadata echo "Testing OAuth AS metadata..." AS_METADATA=$(curl -sf "$BASE/.well-known/oauth-authorization-server") echo "$AS_METADATA" | jq -e '.issuer == "'"$BASE"'"' >/dev/null && pass "AS metadata: issuer matches base URL" || fail "AS metadata: issuer mismatch" echo "$AS_METADATA" | jq -e '.authorization_endpoint == "'"$BASE"'/oauth/authorize"' >/dev/null && pass "AS metadata: authorization_endpoint" || fail "AS metadata: authorization_endpoint" echo "$AS_METADATA" | jq -e '.token_endpoint == "'"$BASE"'/oauth/token"' >/dev/null && pass "AS metadata: token_endpoint" || fail "AS metadata: token_endpoint" echo "$AS_METADATA" | jq -e '.pushed_authorization_request_endpoint == "'"$BASE"'/oauth/par"' >/dev/null && pass "AS metadata: PAR endpoint" || fail "AS metadata: PAR endpoint" echo "$AS_METADATA" | jq -e '.revocation_endpoint == "'"$BASE"'/oauth/revoke"' >/dev/null && pass "AS metadata: revocation_endpoint" || fail "AS metadata: revocation_endpoint" echo "$AS_METADATA" | jq -e '.jwks_uri == "'"$BASE"'/oauth/jwks"' >/dev/null && pass "AS metadata: jwks_uri" || fail "AS metadata: jwks_uri" echo "$AS_METADATA" | jq -e '.scopes_supported | contains(["atproto"])' >/dev/null && pass "AS metadata: scopes_supported includes atproto" || fail "AS metadata: scopes_supported" echo "$AS_METADATA" | jq -e '.response_types_supported | contains(["code"])' >/dev/null && pass "AS metadata: response_types_supported" || fail "AS metadata: response_types_supported" echo "$AS_METADATA" | jq -e '.grant_types_supported | contains(["authorization_code", "refresh_token"])' >/dev/null && pass "AS metadata: grant_types_supported" || fail "AS metadata: grant_types_supported" echo "$AS_METADATA" | jq -e '.code_challenge_methods_supported | contains(["S256"])' >/dev/null && pass "AS metadata: PKCE S256 supported" || fail "AS metadata: PKCE S256" echo "$AS_METADATA" | jq -e '.dpop_signing_alg_values_supported | contains(["ES256"])' >/dev/null && pass "AS metadata: DPoP ES256 supported" || fail "AS metadata: DPoP ES256" echo "$AS_METADATA" | jq -e '.require_pushed_authorization_requests == true' >/dev/null && pass "AS metadata: PAR required" || fail "AS metadata: PAR required" echo "$AS_METADATA" | jq -e '.authorization_response_iss_parameter_supported == true' >/dev/null && pass "AS metadata: iss parameter supported" || fail "AS metadata: iss parameter" # Test OAuth Protected Resource Metadata echo "Testing OAuth PR metadata..." PR_METADATA=$(curl -sf "$BASE/.well-known/oauth-protected-resource") echo "$PR_METADATA" | jq -e '.resource == "'"$BASE"'"' >/dev/null && pass "PR metadata: resource matches base URL" || fail "PR metadata: resource mismatch" echo "$PR_METADATA" | jq -e '.authorization_servers | contains(["'"$BASE"'"])' >/dev/null && pass "PR metadata: authorization_servers" || fail "PR metadata: authorization_servers" echo "$PR_METADATA" | jq -e '.scopes_supported | contains(["atproto"])' >/dev/null && pass "PR metadata: scopes_supported" || fail "PR metadata: scopes_supported" # Test JWKS endpoint echo "Testing JWKS endpoint..." JWKS=$(curl -sf "$BASE/oauth/jwks") echo "$JWKS" | jq -e '.keys | length > 0' >/dev/null && pass "JWKS: has at least one key" || fail "JWKS: no keys" echo "$JWKS" | jq -e '.keys[0].kty == "EC"' >/dev/null && pass "JWKS: key is EC type" || fail "JWKS: key type" echo "$JWKS" | jq -e '.keys[0].crv == "P-256"' >/dev/null && pass "JWKS: key uses P-256 curve" || fail "JWKS: curve" echo "$JWKS" | jq -e '.keys[0].alg == "ES256"' >/dev/null && pass "JWKS: key algorithm is ES256" || fail "JWKS: algorithm" echo "$JWKS" | jq -e '.keys[0].use == "sig"' >/dev/null && pass "JWKS: key use is sig" || fail "JWKS: key use" echo "$JWKS" | jq -e '.keys[0].kid == "pds-oauth-key"' >/dev/null && pass "JWKS: kid is pds-oauth-key" || fail "JWKS: kid" echo "$JWKS" | jq -e '.keys[0] | has("x") and has("y")' >/dev/null && pass "JWKS: has x and y coordinates" || fail "JWKS: coordinates" echo "$JWKS" | jq -e '.keys[0] | has("d") | not' >/dev/null && pass "JWKS: does not expose private key (d)" || fail "JWKS: private key exposed!" # Test PAR endpoint error cases echo "Testing PAR error handling..." PAR_NO_DPOP=$(curl -s -w "\n%{http_code}" -X POST "$BASE/oauth/par" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "client_id=http://localhost:3000&redirect_uri=http://localhost:3000/callback&response_type=code&scope=atproto&code_challenge=test&code_challenge_method=S256") PAR_BODY=$(echo "$PAR_NO_DPOP" | head -n -1) PAR_STATUS=$(echo "$PAR_NO_DPOP" | tail -n 1) [ "$PAR_STATUS" = "400" ] && pass "PAR: rejects missing DPoP (400)" || fail "PAR: should reject missing DPoP" echo "$PAR_BODY" | jq -e '.error == "invalid_dpop_proof"' >/dev/null && pass "PAR: error code is invalid_dpop_proof" || fail "PAR: wrong error code" # Test token endpoint error cases echo "Testing token endpoint error handling..." TOKEN_NO_DPOP=$(curl -s -w "\n%{http_code}" -X POST "$BASE/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code&code=fake&client_id=http://localhost:3000") TOKEN_BODY=$(echo "$TOKEN_NO_DPOP" | head -n -1) TOKEN_STATUS=$(echo "$TOKEN_NO_DPOP" | tail -n 1) [ "$TOKEN_STATUS" = "400" ] && pass "Token: rejects missing DPoP (400)" || fail "Token: should reject missing DPoP" echo "$TOKEN_BODY" | jq -e '.error == "invalid_dpop_proof"' >/dev/null && pass "Token: error code is invalid_dpop_proof" || fail "Token: wrong error code" # Test revoke endpoint (should accept without valid token - RFC 7009 says always 200) echo "Testing revoke endpoint..." REVOKE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/oauth/revoke" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "token=nonexistent&client_id=http://localhost:3000") [ "$REVOKE_STATUS" = "200" ] && pass "Revoke: returns 200 even for invalid token" || fail "Revoke: should always return 200" echo echo "All OAuth endpoint tests passed!" ``` **Step 2: Commit** ```bash git add test/e2e.sh git commit -m "test(oauth): add comprehensive OAuth e2e tests" ``` --- ## Task 13: Run Typecheck and Fix Any Errors **Files:** - Modify: `src/pds.js` (if needed) **Step 1: Run TypeScript type checking** ```bash npm run typecheck ``` Expect: No type errors. If there are errors, fix them before continuing. **Step 2: Run unit tests** ```bash npm test ``` Expect: All tests pass. **Step 3: Run e2e tests** Start wrangler dev in one terminal, then run: ```bash ./test/e2e.sh ``` Expect: All tests pass. **Step 4: Final commit (if any fixes were needed)** ```bash git add src/pds.js git commit -m "fix(oauth): address typecheck errors" ``` --- ## Summary This plan implements AT Protocol OAuth with: - PAR (Pushed Authorization Requests) - DPoP (Demonstration of Proof-of-Possession) - PKCE (Proof Key for Code Exchange) - Authorization code flow with consent UI - Token refresh and revocation - Backward compatibility with existing Bearer tokens All implemented with zero external dependencies using Web Crypto APIs.