this repo has no description

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  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.