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:
// 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
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:
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:
/**
* 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<string>} 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
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
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
/**
* 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<string, { metadata: ClientMetadata, expiresAt: number }>} */
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<ClientMetadata>} 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
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
/**
* @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<DpopProofResult>} 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
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
// 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
/**
* 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
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
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<Response>} 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
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)
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<Response>} 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,'>').replace(/"/g,'"');
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Authorize</title>
<style>body{font-family:system-ui;max-width:400px;margin:40px auto;padding:20px}
.error{color:#c00;background:#fee;padding:10px;margin:10px 0}
button{padding:10px 20px;margin:5px;cursor:pointer}
.approve{background:#06c;color:#fff;border:none}
input{width:100%;padding:8px;margin:5px 0;box-sizing:border-box}</style></head>
<body><h2>Sign in to authorize</h2>
<p><b>${escHtml(clientName)}</b> wants to access your account.</p>
<p>Scope: ${escHtml(scope)}</p>
${error ? `<p class="error">${escHtml(error)}</p>` : ''}
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="request_uri" value="${escHtml(requestUri)}">
<input type="hidden" name="client_id" value="${escHtml(clientId)}">
<label>Password</label><input type="password" name="password" required autofocus>
<div><button type="submit" name="action" value="deny">Deny</button>
<button type="submit" name="action" value="approve" class="approve">Authorize</button></div>
</form></body></html>`;
}
Step 2: Add POST handler (approval)
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<Response>} 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
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
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<Response>} 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<Response>} 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<string>} 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
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
/**
* 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<Response>} 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
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
/**
* @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<AuthResult>} 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<AuthResult>} 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
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
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<Response>} 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
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:
# 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
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
npm run typecheck
Expect: No type errors. If there are errors, fix them before continuing.
Step 2: Run unit tests
npm test
Expect: All tests pass.
Step 3: Run e2e tests
Start wrangler dev in one terminal, then run:
./test/e2e.sh
Expect: All tests pass.
Step 4: Final commit (if any fixes were needed)
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.