~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1772 lines · 56.1 KB Raw History
// @pdsjs/core/handlers/oauth - The authorization-server half of OAuth:
// server metadata, JWKS, PAR, the authorize and consent flow (password and
// passkey), the token grants, and revocation. The resource-server half (DPoP
// access-token verification on API requests) stays in pds.js with dispatch.

import {
  base64UrlDecode,
  base64UrlEncode,
  sign,
  signingKeyCurve,
} from '../crypto.js';
import { isSameOriginPost } from '../http.js';
import {
  fetchClientMetadata,
  getAuthServerMetadata,
  getProtectedResourceMetadata,
  isLoopbackClient,
  isValidDpopNonce,
  mintDpopNonce,
  parseDpopProof,
  renderConsentPage,
  resolveClientJwk,
  verifyEs256WithJwk,
} from '../oauth.js';
import { PASSKEY_COOKIE } from '../passkey.js';
import {
  bareSpaceTypes,
  expandPermissionSet,
  expandScopeString,
  expandSpaceScopes,
  parseIncludeScope,
  parseScopesForDisplay,
  parseSpaceScope,
  permissionSetDef,
  spaceTypeCollections,
} from '../scope.js';
import { verifyAssertion } from '../webauthn.js';

/**
 * @typedef {import('../oauth.js').DpopProofResult} DpopProofResult
 */

/**
 * @typedef {Object} OAuthRequest
 * @property {string} clientId - OAuth client ID
 * @property {Object} [clientMetadata] - Client metadata
 * @property {string} [clientMetadata.client_name] - Client name
 * @property {string[]} [clientMetadata.redirect_uris] - Allowed redirect URIs
 * @property {string} redirectUri - Redirect URI
 * @property {string} scope - OAuth scope
 * @property {string} [state] - OAuth state parameter
 * @property {string} [responseMode] - Response mode (query/fragment)
 * @property {string} [loginHint] - Login hint
 * @property {string} codeChallenge - PKCE code challenge
 * @property {string} codeChallengeMethod - PKCE method (S256)
 * @property {string|null} dpopJkt - DPoP key thumbprint
 * @property {string} [code] - Authorization code (after approval)
 * @property {string} [did] - User DID (after approval)
 * @property {string|null} [userAgent] - Device that consented (after approval)
 * @property {string} [requestUri] - For code mapping lookup
 * @property {Record<string, object>} [permissionSets] - Resolved permission-set
 *   lexicon documents for the request's include: scopes, keyed by NSID
 * @property {string} [requestedScope] - The client's original scope string,
 *   kept when approval expands include: scopes into concrete grants
 * @property {Record<string, string[]>} [spaceTypes] - The collections each
 *   space type declares, for the request's space: scopes that name none
 */

/**
 * @typedef {Object} OAuthToken
 * @property {string} did - User DID
 * @property {string} clientId - OAuth client ID
 * @property {string} scope - OAuth scope
 * @property {string} dpopJkt - DPoP key thumbprint
 * @property {number} createdAt - Token creation timestamp
 * @property {number} [updatedAt] - Last authorization or refresh timestamp
 * @property {string|null} [userAgent] - Device that opened the session
 * @property {string} [rotatedTo] - On a spent refresh token: its replacement
 * @property {string} [replaced] - The spent token this one replaced
 */

/**
 * @typedef {Object} ClientMetadata
 * @property {string} client_id - Client ID
 * @property {string} [client_name] - Client display name
 * @property {string[]} [redirect_uris] - Allowed redirect URIs
 */

/**
 * @typedef {Object} AuthCodeGrantParams
 * @property {string} [code]
 * @property {string} [redirect_uri]
 * @property {string} [client_id]
 * @property {string} [code_verifier]
 */

/**
 * @typedef {Object} RefreshGrantParams
 * @property {string} [refresh_token]
 * @property {string} [client_id]
 */

// RFC 7523 client authentication (private_key_jwt). Confidential OAuth clients
// prove their identity at PAR and the token endpoint with a short-lived JWT
// ("client_assertion") signed by a key in their published JWKS.
const CLIENT_ASSERTION_TYPE_JWT_BEARER =
  'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
const CLIENT_ASSERTION_MAX_AGE_S = 60;

const DAY_MS = 24 * 60 * 60 * 1000;

// How long an OAuth session lasts before the user must re-authorize, from its
// first authorization, and how long it lasts without a refresh. The values
// match the atproto reference AS. A public client has only the first limit.
const OAUTH_PUBLIC_SESSION_TTL_MS = 14 * DAY_MS;
const OAUTH_CONFIDENTIAL_SESSION_TTL_MS = 2 * 365 * DAY_MS;
const OAUTH_CONFIDENTIAL_IDLE_TTL_MS = 90 * DAY_MS;

/**
 * The language-tagged variants of a permission-set text property, e.g.
 * `title:fr` -> `{ fr: '...' }`.
 * @param {object|null} def @param {string} prop
 * @returns {Record<string, string>}
 */
function langVariants(def, prop) {
  /** @type {Record<string, string>} */
  const out = {};
  for (const [key, value] of Object.entries(def ?? {})) {
    if (key.startsWith(`${prop}:`) && typeof value === 'string') {
      out[key.slice(prop.length + 1)] = value;
    }
  }
  return out;
}

/**
 * Parsed scopes as a JSON-serializable shape for the consent app's bootstrap,
 * flattening the repo-permission Map the display parser returns. Include
 * scopes are joined with their resolved permission sets so the page can show
 * the author's title and detail alongside what the set concretely grants.
 * @param {string} scope
 * @param {Record<string, object>|null} [permissionSets]
 */
function serializeScopeDisplay(scope, permissionSets = null) {
  const parsed = parseScopesForDisplay(scope);
  /** @param {Map<string, {create: boolean, update: boolean, delete: boolean}>} repoPermissions */
  const flattenRepo = (repoPermissions) =>
    [...repoPermissions.entries()].map(([collection, actions]) => ({
      collection,
      ...actions,
    }));
  const includes = parsed.includeScopes.map(({ nsid, aud }) => {
    const doc = permissionSets?.[nsid] ?? null;
    const def = permissionSetDef(doc);
    const expanded = def ? (expandPermissionSet(nsid, doc, aud) ?? []) : [];
    const inner = parseScopesForDisplay(expanded.join(' '));
    return {
      nsid,
      aud,
      title: typeof def?.title === 'string' ? def.title : null,
      detail: typeof def?.detail === 'string' ? def.detail : null,
      titleLangs: langVariants(def, 'title'),
      detailLangs: langVariants(def, 'detail'),
      repo: flattenRepo(inner.repoPermissions),
      rpc: inner.rpcPermissions,
    };
  });
  return {
    scope,
    hasAtproto: parsed.hasAtproto,
    hasTransitionGeneric: parsed.hasTransitionGeneric,
    hasEmail: parsed.hasEmail,
    repo: flattenRepo(parsed.repoPermissions),
    rpc: parsed.rpcPermissions,
    includes,
    blobs: parsed.blobPermissions,
    spaces: parsed.spacePermissions,
  };
}

/**
 * @typedef {Object} OAuthContext
 * @property {import('../ports.js').SharedStoragePort} sharedStorage
 * @property {ReturnType<typeof import('../passkey.js').createPasskeyCeremony>} passkeys
 * @property {string} jwtSecret
 * @property {(candidate: unknown) => Promise<boolean>} verifyAccountPassword - For the consent page's password approval
 * @property {() => Promise<boolean>} passwordConfigured
 * @property {boolean} readOnly
 * @property {boolean} spacesEnabled
 * @property {boolean} hasAccountApp - Whether a mounted account application renders the consent page
 * @property {import('../ports.js').LexiconResolverPort} [lexiconResolver]
 * @property {() => Promise<string|null>} getDid
 * @property {() => Promise<import('../crypto.js').SigningKey|null>} getSigningKey
 * @property {() => Promise<Object|null>} getPublicKeyJwk
 * @property {(request: Request) => Promise<any|Response>} readJsonBounded
 * @property {(bootstrap?: Object|null, status?: number) => Response|null} accountAppResponse
 * @property {() => Promise<{theme: string|null}>} readAccountUiState
 * @property {() => Response} readOnlyError
 */

/**
 * @param {OAuthContext} ctx
 */
export function createOAuthHandlers(ctx) {
  const {
    sharedStorage,
    passkeys,
    jwtSecret,
    verifyAccountPassword,
    passwordConfigured,
    readOnly,
    spacesEnabled,
    hasAccountApp,
    lexiconResolver,
    getDid,
    getSigningKey,
    getPublicKeyJwk,
    readJsonBounded,
    accountAppResponse,
    readAccountUiState,
    readOnlyError,
  } = ctx;

  /**
   * Require a valid DPoP nonce on an authorization-server request
   * (RFC 9449 §8). Returns the use_dpop_nonce error response carrying a
   * fresh nonce, or null when the proof's nonce is acceptable.
   * @param {{nonce?: string}} dpop - Parsed DPoP proof
   * @returns {Promise<Response|null>}
   */
  async function requireDpopNonce(dpop) {
    if (await isValidDpopNonce(jwtSecret, dpop.nonce || '')) return null;
    return Response.json(
      {
        error: 'use_dpop_nonce',
        error_description: 'Authorization server requires nonce in DPoP proof',
      },
      {
        status: 400,
        headers: { 'DPoP-Nonce': await mintDpopNonce(jwtSecret) },
      },
    );
  }

  /**
   * /.well-known/oauth-authorization-server
   * @param {Request} _request
   * @param {URL} url
   * @returns {Response}
   */
  function handleOAuthAuthServerMetadata(_request, url) {
    const issuer = `${url.protocol}//${url.host}`;
    return Response.json(getAuthServerMetadata(issuer));
  }

  /**
   * /.well-known/oauth-protected-resource
   * @param {Request} _request
   * @param {URL} url
   * @returns {Response}
   */
  function handleOAuthProtectedResource(_request, url) {
    const resource = `${url.protocol}//${url.host}`;
    return Response.json(getProtectedResourceMetadata(resource));
  }

  /**
   * /oauth/jwks
   * @returns {Promise<Response>}
   */
  async function handleOAuthJwks() {
    const publicKeyJwk = await getPublicKeyJwk();
    if (!publicKeyJwk) {
      return Response.json(
        { error: 'ServerError', message: 'No signing key configured' },
        { status: 500 },
      );
    }

    const alg =
      /** @type {{crv?: string}} */ (publicKeyJwk).crv === 'secp256k1'
        ? 'ES256K'
        : 'ES256';
    return Response.json({
      keys: [{ ...publicKeyJwk, kid: 'pds-oauth-key', use: 'sig', alg }],
    });
  }

  /**
   * Validate OAuth authorization parameters
   * @param {Object} params
   * @param {string} [params.clientId]
   * @param {string|null} [params.redirectUri]
   * @param {string|null} [params.responseType]
   * @param {string|null} [params.codeChallenge]
   * @param {string|null} [params.codeChallengeMethod]
   * @returns {Promise<{error: Response} | {clientMetadata: ClientMetadata}>}
   */
  async function validateAuthorizationParameters({
    clientId,
    redirectUri,
    responseType,
    codeChallenge,
    codeChallengeMethod,
  }) {
    if (!clientId) {
      return {
        error: Response.json(
          { error: 'invalid_request', error_description: 'client_id required' },
          { status: 400 },
        ),
      };
    }
    if (!redirectUri) {
      return {
        error: Response.json(
          {
            error: 'invalid_request',
            error_description: 'redirect_uri required',
          },
          { status: 400 },
        ),
      };
    }
    if (responseType !== 'code') {
      return {
        error: Response.json(
          {
            error: 'unsupported_response_type',
            error_description: 'response_type must be code',
          },
          { status: 400 },
        ),
      };
    }
    if (!codeChallenge || codeChallengeMethod !== 'S256') {
      return {
        error: Response.json(
          {
            error: 'invalid_request',
            error_description: 'PKCE with S256 required',
          },
          { status: 400 },
        ),
      };
    }

    let clientMetadata;
    try {
      clientMetadata = await fetchClientMetadata(clientId);
    } catch (err) {
      return {
        error: Response.json(
          { error: 'invalid_client', error_description: err.message },
          { status: 400 },
        ),
      };
    }

    // Validate redirect_uri
    const isLoopback = isLoopbackClient(clientId);
    const redirectUriValid = clientMetadata.redirect_uris?.some((uri) => {
      if (isLoopback) {
        // RFC 8252 7.3: a native app listens on whatever port it gets, so a
        // loopback redirect matches any port.
        try {
          const registered = new URL(uri);
          const requested = new URL(redirectUri);
          return (
            registered.protocol === requested.protocol &&
            registered.hostname === requested.hostname
          );
        } catch {
          return false;
        }
      }
      return uri === redirectUri;
    });

    if (!redirectUriValid) {
      return {
        error: Response.json(
          {
            error: 'invalid_request',
            error_description: 'redirect_uri not registered for client',
          },
          { status: 400 },
        ),
      };
    }

    return { clientMetadata };
  }

  /**
   * Refuse scope grants for capabilities this server will not serve. With
   * spaces disabled the space endpoints do not exist, so a space: grant would
   * be a dead promise on the consent screen. Applies to both the PAR and the
   * direct authorization paths.
   * @param {string} scope - Space-separated scope string
   * @returns {Response|null} An invalid_scope response, or null to proceed
   */
  function rejectUnservableScopes(scope) {
    if (spacesEnabled) return null;
    const hasSpaceGrant = scope
      .split(' ')
      .some((/** @type {string} */ sc) => parseSpaceScope(sc) !== null);
    if (!hasSpaceGrant) return null;
    return Response.json(
      {
        error: 'invalid_scope',
        error_description: 'space: scopes are not supported by this server',
      },
      { status: 400 },
    );
  }

  /**
   * Resolve every include: scope's permission-set lexicon so consent can show
   * what the set grants and approval can expand it. A request whose include:
   * cannot be resolved to a permission set is refused outright — the
   * alternative is a consent screen that cannot say what it is asking for.
   * Applies to both the PAR and the direct authorization paths.
   * @param {string} scope - Space-separated scope string
   * @returns {Promise<{ permissionSets: Record<string, object>|null } | { error: Response }>}
   */
  async function resolvePermissionSets(scope) {
    const nsids = new Set();
    for (const s of scope.split(' ')) {
      const include = parseIncludeScope(s);
      if (include) nsids.add(include.nsid);
    }
    if (nsids.size === 0) return { permissionSets: null };

    /** @param {string} description @returns {{ error: Response }} */
    const invalid = (description) => ({
      error: Response.json(
        { error: 'invalid_scope', error_description: description },
        { status: 400 },
      ),
    });

    const getLexicon = lexiconResolver?.getLexicon?.bind(lexiconResolver);
    if (!getLexicon) {
      return invalid('include: scopes are not supported by this server');
    }

    /** @type {Record<string, object>} */
    const permissionSets = {};
    for (const nsid of nsids) {
      let doc = null;
      try {
        doc = await getLexicon(nsid);
      } catch {
        // Resolution failure and absence refuse the request identically.
      }
      if (!doc || !permissionSetDef(doc)) {
        return invalid(`include:${nsid} does not name a known permission set`);
      }
      permissionSets[nsid] = doc;
    }
    return { permissionSets };
  }

  /**
   * Resolve the lexicon of each space type that a space: scope names without
   * a collection. Approval writes the declared collections into the scope.
   * A type that does not resolve to a space is left out, and its scope is
   * granted as asked.
   * @param {string} scope - Space-separated scope string
   * @returns {Promise<Record<string, string[]>|null>}
   */
  async function resolveSpaceTypes(scope) {
    const getLexicon = lexiconResolver?.getLexicon?.bind(lexiconResolver);
    if (!getLexicon) return null;
    /** @type {Record<string, string[]>} */
    const spaceTypes = {};
    for (const type of bareSpaceTypes(scope)) {
      let doc = null;
      try {
        doc = await getLexicon(type);
      } catch {
        // A type that fails to resolve is granted as asked.
      }
      const collections = spaceTypeCollections(doc);
      if (collections) spaceTypes[type] = collections;
    }
    return Object.keys(spaceTypes).length > 0 ? spaceTypes : null;
  }

  /**
   * Authenticate an OAuth client for the token/PAR endpoints, per its
   * `token_endpoint_auth_method`. Public clients (`none`) need nothing here —
   * PKCE and DPoP carry them. A confidential client (`private_key_jwt`) must
   * present a `client_assertion` JWT signed by a key in its published JWKS,
   * verified per RFC 7523: `iss`/`sub` are the client_id, `aud` names this
   * server, it is unexpired and recent, and its `jti` has not been seen.
   * @param {any} clientMetadata - the client's fetched metadata
   * @param {Record<string, any>} params - the request body params
   * @param {string} issuer - this server's OAuth issuer URL
   * @returns {Promise<Response|null>} an error Response, or null when authenticated
   */
  async function authenticateClient(clientMetadata, params, issuer) {
    const method = clientMetadata?.token_endpoint_auth_method || 'none';
    /** @param {string} error @param {string} description */
    const fail = (error, description) =>
      Response.json({ error, error_description: description }, { status: 400 });

    if (method === 'none') return null;
    if (method !== 'private_key_jwt') {
      return fail(
        'invalid_client',
        `Unsupported token_endpoint_auth_method "${method}"`,
      );
    }

    const assertion = params.client_assertion;
    if (!assertion) {
      return fail(
        'invalid_client',
        'client_assertion required for private_key_jwt',
      );
    }
    if (params.client_assertion_type !== CLIENT_ASSERTION_TYPE_JWT_BEARER) {
      return fail(
        'invalid_request',
        `Unsupported client_assertion_type "${params.client_assertion_type}"`,
      );
    }

    const parts = String(assertion).split('.');
    if (parts.length !== 3)
      return fail('invalid_client', 'Malformed client_assertion');
    let header;
    let payload;
    try {
      header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0])));
      payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1])));
    } catch {
      return fail('invalid_client', 'Malformed client_assertion');
    }

    if (header.alg !== 'ES256') {
      return fail('invalid_client', 'client_assertion alg must be ES256');
    }
    if (!header.kid) {
      return fail('invalid_client', '"kid" required in client_assertion');
    }

    const clientId = clientMetadata.client_id;
    const now = Math.floor(Date.now() / 1000);
    // RFC 7523 §3: for client auth, iss and sub both are the client_id.
    if (payload.iss !== clientId || payload.sub !== clientId) {
      return fail(
        'invalid_client',
        'client_assertion "iss"/"sub" must be the client_id',
      );
    }
    // aud must identify this authorization server (its issuer or an endpoint
    // under it). The trailing slash stops "https://me" matching "https://me.evil".
    const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
    if (
      !aud.some(
        (/** @type {string} */ a) =>
          a === issuer || (typeof a === 'string' && a.startsWith(`${issuer}/`)),
      )
    ) {
      return fail(
        'invalid_client',
        'client_assertion "aud" must be this server',
      );
    }
    if (typeof payload.exp === 'number' && payload.exp <= now) {
      return fail('invalid_client', 'client_assertion has expired');
    }
    // Bound the age even when "exp" is absent.
    if (
      typeof payload.iat !== 'number' ||
      payload.iat < now - CLIENT_ASSERTION_MAX_AGE_S
    ) {
      return fail(
        'invalid_client',
        'client_assertion "iat" missing or too old',
      );
    }
    if (!payload.jti || typeof payload.jti !== 'string') {
      return fail('invalid_client', '"jti" required in client_assertion');
    }

    const jwk = await resolveClientJwk(clientMetadata, header.kid);
    if (!jwk) {
      return fail('invalid_client', 'No matching key in the client JWKS');
    }
    let validSig = false;
    try {
      validSig = await verifyEs256WithJwk(
        jwk,
        `${parts[0]}.${parts[1]}`,
        parts[2],
      );
    } catch {
      validSig = false;
    }
    if (!validSig) {
      return fail('invalid_client', 'client_assertion signature is invalid');
    }

    // Replay protection for the assertion's lifetime, in the same store DPoP
    // jtis use (namespaced so the two cannot collide).
    const jtiExpiry =
      typeof payload.exp === 'number'
        ? payload.exp * 1000
        : Date.now() + CLIENT_ASSERTION_MAX_AGE_S * 1000;
    const fresh = await sharedStorage.checkAndStoreDpopJti(
      `client_assertion:${payload.jti}`,
      jtiExpiry,
    );
    if (!fresh) {
      return fail('invalid_client', 'client_assertion replay detected');
    }

    return null;
  }

  /**
   * /oauth/par - Pushed Authorization Request
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleOAuthPar(request, url) {
    if (readOnly) return readOnlyError();

    const issuer = `${url.protocol}//${url.host}`;

    // Parse the body before any early return: responding without draining
    // the request stream raises an uncaught error on Workers runtimes
    const parContentType = request.headers.get('content-type') || '';
    let data;
    if (parContentType.includes('application/json')) {
      data = await request.json();
    } else {
      const parText = await request.text();
      data = Object.fromEntries(new URLSearchParams(parText));
    }

    // RFC 9449 §10.1: a pushed request names its DPoP key with a proof or
    // with the dpop_jkt parameter. A client that sends neither binds the key
    // at the token endpoint, which still requires a proof.
    const dpopHeader = request.headers.get('DPoP');
    let dpop = null;
    if (dpopHeader) {
      try {
        dpop = await parseDpopProof(dpopHeader, 'POST', `${issuer}/oauth/par`);
      } catch (err) {
        return Response.json(
          { error: 'invalid_dpop_proof', error_description: err.message },
          { status: 400 },
        );
      }

      const nonceError = await requireDpopNonce(dpop);
      if (nonceError) return nonceError;

      // Check DPoP replay
      const fresh = await sharedStorage.checkAndStoreDpopJti(
        dpop.jti,
        Date.now() + 300000,
      );
      if (!fresh) {
        return Response.json(
          {
            error: 'invalid_dpop_proof',
            message: 'DPoP proof replay detected',
          },
          { status: 400 },
        );
      }

      if (data.dpop_jkt && data.dpop_jkt !== dpop.jkt) {
        return Response.json(
          {
            error: 'invalid_dpop_proof',
            error_description: 'dpop_jkt does not match the DPoP proof',
          },
          { status: 400 },
        );
      }
    }

    const dpopJkt = dpop ? dpop.jkt : data.dpop_jkt || null;

    const validationResult = await validateAuthorizationParameters({
      clientId: data.client_id,
      redirectUri: data.redirect_uri,
      responseType: data.response_type,
      codeChallenge: data.code_challenge,
      codeChallengeMethod: data.code_challenge_method,
    });
    if ('error' in validationResult) return validationResult.error;

    // A confidential client must authenticate the pushed request.
    const clientAuthError = await authenticateClient(
      validationResult.clientMetadata,
      data,
      issuer,
    );
    if (clientAuthError) return clientAuthError;

    const scopeError = rejectUnservableScopes(data.scope || 'atproto');
    if (scopeError) return scopeError;

    const setsResult = await resolvePermissionSets(data.scope || 'atproto');
    if ('error' in setsResult) return setsResult.error;

    const spaceTypes = await resolveSpaceTypes(data.scope || 'atproto');

    const requestId = crypto.randomUUID();
    const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
    const expiresIn = 600;
    const expiresAt = Date.now() + expiresIn * 1000;

    await sharedStorage.putOAuthRequest(
      requestUri,
      {
        clientId: data.client_id,
        clientMetadata: validationResult.clientMetadata,
        redirectUri: data.redirect_uri,
        scope: data.scope || 'atproto',
        ...(setsResult.permissionSets && {
          permissionSets: setsResult.permissionSets,
        }),
        ...(spaceTypes && { spaceTypes }),
        state: data.state,
        responseMode: data.response_mode,
        loginHint: data.login_hint,
        codeChallenge: data.code_challenge,
        codeChallengeMethod: data.code_challenge_method,
        dpopJkt,
      },
      expiresAt,
    );

    return Response.json(
      { request_uri: requestUri, expires_in: expiresIn },
      {
        // RFC 9126 §2.2: a successful PAR response is 201 Created. Clients
        // such as indigo treat any other status as a failure.
        status: 201,
        headers: { 'DPoP-Nonce': await mintDpopNonce(jwtSecret) },
      },
    );
  }

  /**
   * /oauth/authorize - Authorization endpoint
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleOAuthAuthorize(request, url) {
    if (request.method === 'GET') {
      return handleOAuthAuthorizeGet(url);
    } else if (request.method === 'POST') {
      return handleOAuthAuthorizePost(request, url);
    }
    return Response.json({ error: 'MethodNotAllowed' }, { status: 405 });
  }

  /**
   * GET /oauth/authorize - Display consent page
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleOAuthAuthorizeGet(url) {
    const requestUri = url.searchParams.get('request_uri');
    const clientId = url.searchParams.get('client_id');

    if (requestUri) {
      if (!clientId) {
        return new Response('Missing client_id parameter', { status: 400 });
      }

      const authRequest = /** @type {OAuthRequest|null} */ (
        await sharedStorage.getOAuthRequest(requestUri)
      );
      if (!authRequest) {
        return new Response('Request not found or expired', { status: 400 });
      }
      if (authRequest.clientId !== clientId) {
        return new Response('Client mismatch', { status: 400 });
      }
      if (authRequest.code) {
        return new Response('Request already used', { status: 400 });
      }

      return consentResponse(
        {
          clientName: authRequest.clientMetadata?.client_name || clientId,
          clientId,
          scope: authRequest.scope || 'atproto',
          permissionSets: authRequest.permissionSets,
          requestUri,
          loginHint: authRequest.loginHint || '',
        },
        url,
      );
    }

    // Direct authorization flow
    if (!clientId) {
      return new Response('Missing client_id parameter', { status: 400 });
    }

    const validationResult = await validateAuthorizationParameters({
      clientId,
      redirectUri: url.searchParams.get('redirect_uri'),
      responseType: url.searchParams.get('response_type'),
      codeChallenge: url.searchParams.get('code_challenge'),
      codeChallengeMethod: url.searchParams.get('code_challenge_method'),
    });
    if ('error' in validationResult) return validationResult.error;

    // Same rule as PAR: never accept a space: grant this server will not
    // serve. This is the direct (PAR-less) authorization path.
    const scopeError = rejectUnservableScopes(
      url.searchParams.get('scope') || 'atproto',
    );
    if (scopeError) return scopeError;

    const setsResult = await resolvePermissionSets(
      url.searchParams.get('scope') || 'atproto',
    );
    if ('error' in setsResult) return setsResult.error;

    const spaceTypes = await resolveSpaceTypes(
      url.searchParams.get('scope') || 'atproto',
    );

    const requestId = crypto.randomUUID();
    const newRequestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
    const expiresAt = Date.now() + 600000;

    await sharedStorage.putOAuthRequest(
      newRequestUri,
      {
        clientId,
        clientMetadata: validationResult.clientMetadata,
        redirectUri: url.searchParams.get('redirect_uri'),
        scope: url.searchParams.get('scope') || 'atproto',
        ...(setsResult.permissionSets && {
          permissionSets: setsResult.permissionSets,
        }),
        ...(spaceTypes && { spaceTypes }),
        state: url.searchParams.get('state'),
        responseMode: url.searchParams.get('response_mode'),
        loginHint: url.searchParams.get('login_hint'),
        codeChallenge: url.searchParams.get('code_challenge'),
        codeChallengeMethod: url.searchParams.get('code_challenge_method'),
        dpopJkt: null,
      },
      expiresAt,
    );

    return consentResponse(
      {
        clientName: validationResult.clientMetadata.client_name || clientId,
        clientId,
        scope: url.searchParams.get('scope') || 'atproto',
        permissionSets: setsResult.permissionSets ?? undefined,
        requestUri: newRequestUri,
        loginHint: url.searchParams.get('login_hint') || '',
      },
      url,
    );
  }

  /**
   * POST /oauth/authorize - Process user approval/denial
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleOAuthAuthorizePost(request, url) {
    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');

    // The account app asks for JSON so it can show its own redirect screen
    // while the client's page loads. A plain form post gets the 302.
    const wantsJson = (request.headers.get('accept') || '').includes(
      'application/json',
    );

    if (!requestUri || !clientId) {
      return authorizeError('Missing request_uri or client_id', wantsJson);
    }

    const authRequest = /** @type {OAuthRequest|null} */ (
      await sharedStorage.getOAuthRequest(requestUri)
    );
    if (!authRequest) {
      return authorizeError('Request not found', wantsJson);
    }
    if (authRequest.clientId !== clientId) {
      return authorizeError('Client mismatch', wantsJson);
    }

    if (action === 'deny') {
      await sharedStorage.deleteOAuthRequest(requestUri);
      const errorUrl = new URL(authRequest.redirectUri);
      errorUrl.searchParams.set('error', 'access_denied');
      if (authRequest.state)
        errorUrl.searchParams.set('state', authRequest.state);
      errorUrl.searchParams.set('iss', issuer);
      return authorizeRedirect(errorUrl.toString(), wantsJson);
    }

    // Verify password
    if (
      (await passwordConfigured()) &&
      !(await verifyAccountPassword(password))
    ) {
      if (wantsJson) {
        return Response.json({ error: 'Invalid password' }, { status: 401 });
      }
      return consentResponse(
        {
          clientName: authRequest.clientMetadata?.client_name || clientId,
          clientId,
          scope: authRequest.scope || 'atproto',
          permissionSets: authRequest.permissionSets,
          requestUri,
          loginHint: authRequest.loginHint || '',
          error: 'Invalid password',
        },
        url,
      );
    }

    const redirect = await issueAuthorizationCode(
      authRequest,
      requestUri,
      issuer,
      (request.headers.get('user-agent') || '').slice(0, 200) || null,
    );
    if (!redirect) {
      return authorizeError('PDS not initialized', wantsJson);
    }
    return authorizeRedirect(redirect, wantsJson);
  }

  /**
   * A refused authorization: JSON the account app shows in place, or the text
   * the browser lands on.
   * @param {string} message
   * @param {boolean} wantsJson
   * @returns {Response}
   */
  function authorizeError(message, wantsJson) {
    return wantsJson
      ? Response.json({ error: message }, { status: 400 })
      : new Response(message, { status: 400 });
  }

  /**
   * The end of an authorization, approved or denied: a browser following the
   * form post gets the 302, a fetch gets the same URL to navigate to itself.
   * @param {string} redirect
   * @param {boolean} wantsJson
   * @returns {Response}
   */
  function authorizeRedirect(redirect, wantsJson) {
    if (!wantsJson) return Response.redirect(redirect, 302);
    return Response.json(
      { redirect },
      { headers: { 'Cache-Control': 'no-store' } },
    );
  }

  /**
   * Approve a request: mint its code and build the redirect back to the
   * client. One path for every way consent can be given, so a passkey
   * approval issues exactly what a password approval does.
   * @param {OAuthRequest} authRequest
   * @param {string} requestUri
   * @param {string} issuer
   * @param {string|null} [userAgent] - the device consenting, captured here
   *   rather than at the token endpoint (a confidential client redeems the code
   *   from its own server, so the token request is not the user's device)
   * @returns {Promise<string|null>} Redirect URL, or null when uninitialized
   */
  async function issueAuthorizationCode(
    authRequest,
    requestUri,
    issuer,
    userAgent = null,
  ) {
    const code = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
    const did = await getDid();
    if (!did) return null;

    // Approval freezes include: grants: the permission sets shown on the
    // consent screen expand into the concrete scopes they granted at that
    // moment, so tokens and enforcement never depend on a lexicon that may
    // change or vanish later. A set's updates take effect on re-consent.
    if (authRequest.permissionSets) {
      authRequest.requestedScope = authRequest.scope;
      authRequest.scope = expandScopeString(
        authRequest.scope || 'atproto',
        authRequest.permissionSets,
      );
    }
    if (authRequest.spaceTypes) {
      authRequest.requestedScope ??= authRequest.scope;
      authRequest.scope = expandSpaceScopes(
        authRequest.scope || 'atproto',
        authRequest.spaceTypes,
      );
    }

    // Update request with code
    authRequest.code = code;
    authRequest.did = did;
    if (userAgent) authRequest.userAgent = userAgent;
    await sharedStorage.putOAuthRequest(
      requestUri,
      authRequest,
      Date.now() + 600000,
    );
    // Store code->requestUri mapping for token exchange lookup
    await sharedStorage.putOAuthRequest(
      `code:${code}`,
      { requestUri },
      Date.now() + 600000,
    );

    const successUrl = new URL(authRequest.redirectUri);
    if (authRequest.responseMode === 'fragment') {
      const fragParams = new URLSearchParams();
      fragParams.set('code', code);
      if (authRequest.state) fragParams.set('state', authRequest.state);
      fragParams.set('iss', issuer);
      successUrl.hash = fragParams.toString();
    } else {
      successUrl.searchParams.set('code', code);
      if (authRequest.state)
        successUrl.searchParams.set('state', authRequest.state);
      successUrl.searchParams.set('iss', issuer);
    }
    return successUrl.toString();
  }

  /**
   * A consent response: the page, and when this server can check a passkey,
   * the ceremony whose challenge the page's approve button will answer.
   * @param {Parameters<typeof renderConsentPage>[0]} params
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function consentResponse(params, url) {
    let passkey = null;
    let cookie = null;
    if (passkeys.passkeysAvailable()) {
      const ceremony = await passkeys.startPasskeyCeremony(
        'oauth-consent',
        url,
        '/oauth',
      );
      passkey = {
        challenge: ceremony.challenge,
        rpId: passkeys.passkeyRpId(url),
      };
      cookie = ceremony.cookie;
    }

    // The account app renders consent client-side from the same components as
    // the console. It POSTs the same fields to the same endpoints, so the flow
    // is unchanged; only the presentation moves.
    if (hasAccountApp) {
      // Non-null: accountAppResponse only returns null when accountApp is unset.
      const response = /** @type {Response} */ (
        accountAppResponse({
          mode: 'consent',
          theme: (await readAccountUiState()).theme,
          clientName: params.clientName,
          clientId: params.clientId,
          requestUri: params.requestUri,
          loginHint: params.loginHint || '',
          error: params.error || '',
          permissions: serializeScopeDisplay(
            params.scope || 'atproto',
            params.permissionSets ?? null,
          ),
          passkey,
        })
      );
      if (cookie) response.headers.set('Set-Cookie', cookie);
      return response;
    }

    /** @type {Record<string, string>} */
    const headers = { 'Content-Type': 'text/html; charset=utf-8' };
    if (cookie) headers['Set-Cookie'] = cookie;
    return new Response(renderConsentPage({ ...params, passkey }), { headers });
  }

  /**
   * POST /oauth/authorize/passkey - Approve a request with a passkey
   *
   * The same consent the password field gives, proven by an assertion
   * instead. The challenge rides the cookie the consent page set, typed
   * 'oauth-consent' so a sign-in challenge cannot be spent here.
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleOAuthAuthorizePasskey(request, url) {
    const body = await readJsonBounded(request);
    if (body instanceof Response) return body;

    if (!isSameOriginPost(request, url)) {
      return Response.json({ error: 'Forbidden' }, { status: 403 });
    }
    if (!sharedStorage.getPasskey) {
      return Response.json(
        { error: 'This server does not support passkeys' },
        { status: 400 },
      );
    }

    const challenge = await passkeys.readPasskeyChallenge(
      request,
      'oauth-consent',
    );
    if (!challenge) {
      return Response.json(
        { error: 'That authorization expired. Reload and try again.' },
        { status: 400 },
      );
    }

    const requestUri = body.request_uri || '';
    const authRequest = /** @type {OAuthRequest|null} */ (
      await sharedStorage.getOAuthRequest(requestUri)
    );
    if (!authRequest || authRequest.clientId !== body.client_id) {
      return Response.json(
        { error: 'Request not found or expired' },
        { status: 400 },
      );
    }
    if (authRequest.code) {
      return Response.json({ error: 'Request already used' }, { status: 400 });
    }

    const did = await getDid();
    const passkey = await sharedStorage.getPasskey(body.credentialId || '');
    if (!passkey || !did || passkey.did !== did) {
      return Response.json(
        { error: 'That passkey is not registered here.' },
        { status: 401 },
      );
    }

    let result;
    try {
      result = await verifyAssertion({
        publicKey: passkey.publicKey,
        algorithm: passkey.algorithm,
        authenticatorData: body.authenticatorData,
        clientDataJSON: body.clientDataJSON,
        signature: body.signature,
        expectedChallenge: challenge,
        expectedOrigin: `${url.protocol}//${url.host}`,
        rpId: passkeys.passkeyRpId(url),
      });
    } catch (err) {
      return Response.json(
        { error: err instanceof Error ? err.message : 'Authorization failed' },
        { status: 401 },
      );
    }

    if (
      result.signCount > 0 &&
      passkey.signCount > 0 &&
      result.signCount <= passkey.signCount
    ) {
      return Response.json(
        { error: 'That passkey looks cloned and was refused.' },
        { status: 401 },
      );
    }
    if (sharedStorage.touchPasskey) {
      await sharedStorage.touchPasskey(
        passkey.credentialId,
        result.signCount,
        new Date().toISOString(),
      );
    }

    const redirect = await issueAuthorizationCode(
      authRequest,
      requestUri,
      `${url.protocol}//${url.host}`,
      (request.headers.get('user-agent') || '').slice(0, 200) || null,
    );
    if (!redirect) {
      return Response.json({ error: 'PDS not initialized' }, { status: 400 });
    }

    // The challenge is spent with the approval it authorized
    return new Response(JSON.stringify({ redirect }), {
      headers: {
        'Content-Type': 'application/json',
        'Cache-Control': 'no-store',
        'Set-Cookie': [
          `${PASSKEY_COOKIE}=`,
          'Path=/oauth',
          'HttpOnly',
          'SameSite=Lax',
          'Max-Age=0',
          url.protocol === 'https:' ? 'Secure' : null,
        ]
          .filter(Boolean)
          .join('; '),
      },
    });
  }

  /**
   * /oauth/token - Token endpoint
   * @param {Request} request
   * @param {URL} url
   * @returns {Promise<Response>}
   */
  async function handleOAuthToken(request, url) {
    if (readOnly) return readOnlyError();

    const issuer = `${url.protocol}//${url.host}`;

    // Parse the body before any early return: responding without draining
    // the request stream raises an uncaught error on Workers runtimes
    const tokenContentType = request.headers.get('content-type') || '';
    let params;
    if (tokenContentType.includes('application/json')) {
      params = await request.json();
    } else {
      const tokenText = await request.text();
      params = Object.fromEntries(new URLSearchParams(tokenText));
    }

    // Validate DPoP
    const dpopHeader = request.headers.get('DPoP');
    if (!dpopHeader) {
      return Response.json(
        {
          error: 'invalid_dpop_proof',
          error_description: 'DPoP proof required',
        },
        { status: 400 },
      );
    }

    let dpop;
    try {
      dpop = await parseDpopProof(dpopHeader, 'POST', `${issuer}/oauth/token`);
    } catch (err) {
      return Response.json(
        { error: 'invalid_dpop_proof', error_description: err.message },
        { status: 400 },
      );
    }

    const nonceError = await requireDpopNonce(dpop);
    if (nonceError) return nonceError;

    // Check DPoP replay
    const fresh = await sharedStorage.checkAndStoreDpopJti(
      dpop.jti,
      Date.now() + 300000,
    );
    if (!fresh) {
      return Response.json(
        { error: 'invalid_dpop_proof', message: 'DPoP proof replay detected' },
        { status: 400 },
      );
    }

    const grantType = params.grant_type;

    const userAgent =
      (request.headers.get('user-agent') || '').slice(0, 200) || null;

    if (grantType === 'authorization_code') {
      return handleAuthCodeGrant(params, dpop, issuer, userAgent);
    } else if (grantType === 'refresh_token') {
      return handleRefreshGrant(params, dpop, issuer);
    }

    return Response.json({ error: 'unsupported_grant_type' }, { status: 400 });
  }

  /**
   * Handle authorization_code grant
   * @param {AuthCodeGrantParams} params
   * @param {DpopProofResult} dpop
   * @param {string} issuer
   * @param {string|null} [userAgent] - the device that opened the session
   * @returns {Promise<Response>}
   */
  async function handleAuthCodeGrant(params, dpop, issuer, userAgent = null) {
    const { code, redirect_uri, client_id, code_verifier } = params;

    if (!code || !redirect_uri || !client_id || !code_verifier) {
      return Response.json(
        {
          error: 'invalid_request',
          error_description: 'Missing required parameters',
        },
        { status: 400 },
      );
    }

    // Look up code → requestUri mapping
    const codeMapping = /** @type {OAuthRequest|null} */ (
      await sharedStorage.getOAuthRequest(`code:${code}`)
    );
    if (!codeMapping) {
      return Response.json(
        {
          error: 'invalid_grant',
          error_description: 'Invalid authorization code',
        },
        { status: 400 },
      );
    }

    // Look up the full auth request
    if (!codeMapping.requestUri) {
      return Response.json(
        { error: 'invalid_grant', error_description: 'Invalid code mapping' },
        { status: 400 },
      );
    }
    const authRequest = /** @type {OAuthRequest|null} */ (
      await sharedStorage.getOAuthRequest(codeMapping.requestUri)
    );
    if (!authRequest || authRequest.code !== code) {
      return Response.json(
        {
          error: 'invalid_grant',
          error_description: 'Invalid authorization code',
        },
        { status: 400 },
      );
    }

    // Validate redirect_uri matches
    if (authRequest.redirectUri !== redirect_uri) {
      return Response.json(
        { error: 'invalid_grant', error_description: 'Redirect URI mismatch' },
        { status: 400 },
      );
    }

    // Validate client_id matches
    if (authRequest.clientId !== client_id) {
      return Response.json(
        { error: 'invalid_grant', error_description: 'Client ID mismatch' },
        { status: 400 },
      );
    }

    // Authenticate the client (a confidential client presents a client_assertion
    // here too). The client metadata was captured and stored at PAR time.
    const clientAuthError = await authenticateClient(
      authRequest.clientMetadata,
      params,
      issuer,
    );
    if (clientAuthError) return clientAuthError;

    // Validate PKCE: code_verifier against stored code_challenge
    const challengeBuffer = await crypto.subtle.digest(
      'SHA-256',
      new TextEncoder().encode(code_verifier),
    );
    const computedChallenge = base64UrlEncode(new Uint8Array(challengeBuffer));
    if (computedChallenge !== authRequest.codeChallenge) {
      return Response.json(
        {
          error: 'invalid_grant',
          message: 'Invalid code_verifier - PKCE verification failed',
        },
        { status: 400 },
      );
    }

    // Validate DPoP key matches the one from PAR
    if (authRequest.dpopJkt && authRequest.dpopJkt !== dpop.jkt) {
      return Response.json(
        { error: 'invalid_dpop_proof', error_description: 'DPoP key mismatch' },
        { status: 400 },
      );
    }

    // Delete the auth request (one-time use)
    await sharedStorage.deleteOAuthRequest(`code:${code}`);
    await sharedStorage.deleteOAuthRequest(codeMapping.requestUri);

    if (!authRequest.did) {
      return Response.json(
        {
          error: 'invalid_grant',
          error_description: 'Authorization incomplete',
        },
        { status: 400 },
      );
    }

    // Create tokens
    const tokenId = crypto.randomUUID();
    const refreshToken = base64UrlEncode(
      crypto.getRandomValues(new Uint8Array(32)),
    );
    const expiresIn = 3600;

    // Create access token JWT
    const accessToken = await createOAuthAccessToken({
      issuer,
      subject: authRequest.did,
      clientId: client_id,
      scope: authRequest.scope || 'atproto',
      tokenId,
      dpopJkt: dpop.jkt,
      expiresIn,
    });

    // Store refresh token. Prefer the device captured at consent; the token
    // request's own user-agent is a confidential client's server, not the user.
    const now = Date.now();
    await sharedStorage.putOAuthToken(refreshToken, {
      did: authRequest.did,
      clientId: client_id,
      scope: authRequest.scope || 'atproto',
      dpopJkt: dpop.jkt,
      createdAt: now,
      updatedAt: now,
      userAgent: authRequest.userAgent || userAgent,
    });

    return Response.json(
      {
        access_token: accessToken,
        token_type: 'DPoP',
        expires_in: expiresIn,
        refresh_token: refreshToken,
        scope: authRequest.scope || 'atproto',
        sub: authRequest.did,
      },
      { headers: { 'DPoP-Nonce': await mintDpopNonce(jwtSecret) } },
    );
  }

  /**
   * Handle refresh_token grant
   * @param {RefreshGrantParams} params
   * @param {DpopProofResult} dpop
   * @param {string} issuer
   * @returns {Promise<Response>}
   */
  async function handleRefreshGrant(params, dpop, issuer) {
    const { refresh_token, client_id } = params;

    if (!refresh_token || !client_id) {
      return Response.json(
        {
          error: 'invalid_request',
          error_description: 'Missing required parameters',
        },
        { status: 400 },
      );
    }

    const token = /** @type {OAuthToken|null} */ (
      await sharedStorage.getOAuthToken(refresh_token)
    );
    if (!token) {
      return Response.json(
        { error: 'invalid_grant', error_description: 'Invalid refresh token' },
        { status: 400 },
      );
    }
    if (token.clientId !== client_id) {
      return Response.json(
        { error: 'invalid_grant', error_description: 'Client mismatch' },
        { status: 400 },
      );
    }
    if (token.dpopJkt !== dpop.jkt) {
      return Response.json(
        { error: 'invalid_dpop_proof', error_description: 'DPoP key mismatch' },
        { status: 400 },
      );
    }

    // Authenticate the client — a confidential client authenticates refreshes
    // too. Its metadata isn't stored with the token, so re-fetch it.
    let refreshClientMetadata;
    try {
      refreshClientMetadata = await fetchClientMetadata(client_id);
    } catch (err) {
      return Response.json(
        { error: 'invalid_client', error_description: err.message },
        { status: 400 },
      );
    }
    const clientAuthError = await authenticateClient(
      refreshClientMetadata,
      params,
      issuer,
    );
    if (clientAuthError) return clientAuthError;

    // authenticateClient proved the key of a private_key_jwt client above.
    const now = Date.now();
    const expired =
      refreshClientMetadata.token_endpoint_auth_method === 'private_key_jwt'
        ? now - token.createdAt > OAUTH_CONFIDENTIAL_SESSION_TTL_MS ||
          now - (token.updatedAt ?? token.createdAt) >
            OAUTH_CONFIDENTIAL_IDLE_TTL_MS
        : now - token.createdAt > OAUTH_PUBLIC_SESSION_TTL_MS;
    if (expired) {
      await sharedStorage.deleteOAuthToken(refresh_token);
      return Response.json(
        { error: 'invalid_grant', error_description: 'Refresh token expired' },
        { status: 400 },
      );
    }

    // Already used: answer with what using it produced, until the client
    // shows it has that by using it. A client that never got the reply comes
    // back with this one, however much later: a browser app is many pages of
    // one session, and a reload, a link followed or a laptop going to sleep
    // while a refresh is in flight loses the new tokens before they're
    // stored. Refusing it would cost the member their session. The tokens
    // are DPoP-bound, so answering a replay gives nothing to anyone without
    // the session's key. The replacement is looked up rather than
    // remembered, so a session revoked in the meantime stays revoked.
    if (token.rotatedTo) {
      const current = /** @type {OAuthToken|null} */ (
        await sharedStorage.getOAuthToken(token.rotatedTo)
      );
      if (!current || current.rotatedTo) {
        await sharedStorage.deleteOAuthToken(refresh_token);
        return Response.json(
          {
            error: 'invalid_grant',
            error_description: 'Invalid refresh token',
          },
          { status: 400 },
        );
      }
      const expiresIn = 3600;
      return Response.json(
        {
          access_token: await createOAuthAccessToken({
            issuer,
            subject: current.did,
            clientId: client_id,
            scope: current.scope,
            tokenId: crypto.randomUUID(),
            dpopJkt: dpop.jkt,
            expiresIn,
          }),
          token_type: 'DPoP',
          expires_in: expiresIn,
          refresh_token: token.rotatedTo,
          scope: current.scope,
          sub: current.did,
        },
        { headers: { 'DPoP-Nonce': await mintDpopNonce(jwtSecret) } },
      );
    }

    // Create new tokens
    const newTokenId = crypto.randomUUID();
    const newRefreshToken = base64UrlEncode(
      crypto.getRandomValues(new Uint8Array(32)),
    );
    const expiresIn = 3600;

    // Create access token JWT
    const accessToken = await createOAuthAccessToken({
      issuer,
      subject: token.did,
      clientId: client_id,
      scope: token.scope,
      tokenId: newTokenId,
      dpopJkt: dpop.jkt,
      expiresIn,
    });

    // Update token in storage. createdAt carries over so it keeps meaning
    // "first authorized"; updatedAt advances to mark this refresh as the
    // session's most recent activity.
    await sharedStorage.putOAuthToken(newRefreshToken, {
      did: token.did,
      clientId: client_id,
      scope: token.scope,
      dpopJkt: dpop.jkt,
      createdAt: token.createdAt,
      updatedAt: Date.now(),
      userAgent: token.userAgent ?? null,
      replaced: refresh_token,
    });
    // The one just used stays, pointing at the one that replaces it, so a
    // page that never got this reply is answered rather than signed out. The
    // one it replaced goes: using this one shows the client has it, leaving
    // a session one spent token at a time.
    await sharedStorage.putOAuthToken(refresh_token, {
      ...token,
      rotatedTo: newRefreshToken,
    });
    if (token.replaced) await sharedStorage.deleteOAuthToken(token.replaced);

    return Response.json(
      {
        access_token: accessToken,
        token_type: 'DPoP',
        expires_in: expiresIn,
        refresh_token: newRefreshToken,
        scope: token.scope,
        sub: token.did,
      },
      { headers: { 'DPoP-Nonce': await mintDpopNonce(jwtSecret) } },
    );
  }

  /**
   * Create OAuth access token JWT
   * @param {Object} options
   * @param {string} options.issuer
   * @param {string} options.subject
   * @param {string} options.clientId
   * @param {string} options.scope
   * @param {string} options.tokenId
   * @param {string} options.dpopJkt
   * @param {number} options.expiresIn
   * @returns {Promise<string>}
   */
  async function createOAuthAccessToken({
    issuer,
    subject,
    clientId,
    scope,
    tokenId,
    dpopJkt,
    expiresIn,
  }) {
    const signingKey = await getSigningKey();
    if (!signingKey) {
      throw new Error('No signing key available');
    }

    const now = Math.floor(Date.now() / 1000);
    const alg =
      signingKeyCurve(signingKey) === 'secp256k1' ? 'ES256K' : 'ES256';
    const header = { typ: 'at+jwt', alg, 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 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(signingKey, sigInput);

    return `${headerB64}.${payloadB64}.${base64UrlEncode(sig)}`;
  }

  /**
   * /oauth/revoke - Token revocation
   * @param {Request} request
   * @returns {Promise<Response>}
   */
  async function handleOAuthRevoke(request) {
    if (readOnly) return readOnlyError();

    const contentType = request.headers.get('content-type') || '';
    let params;
    if (contentType.includes('application/json')) {
      params = await request.json();
    } else {
      const text = await request.text();
      params = Object.fromEntries(new URLSearchParams(text));
    }

    const { token, client_id } = params;
    if (!token || !client_id) {
      return Response.json({ error: 'invalid_request' }, { status: 400 });
    }

    // Try to delete as refresh token
    const storedToken = /** @type {OAuthToken|null} */ (
      await sharedStorage.getOAuthToken(token)
    );
    if (storedToken && storedToken.clientId === client_id) {
      await sharedStorage.deleteOAuthToken(token);
    }

    // Always return 200 per RFC 7009
    return new Response(null, { status: 200 });
  }
  return {
    authenticateClient,
    createOAuthAccessToken,
    issueAuthorizationCode,
    handleOAuthJwks,
    handleOAuthPar,
    /** @type {import('../pds.js').Routes} */
    routes: {
      '/.well-known/oauth-authorization-server': {
        handler: handleOAuthAuthServerMetadata,
      },
      '/.well-known/oauth-protected-resource': {
        handler: handleOAuthProtectedResource,
      },
      '/oauth/jwks': {
        handler: handleOAuthJwks,
      },
      '/oauth/par': {
        method: 'POST',
        handler: handleOAuthPar,
      },
      '/oauth/authorize': {
        handler: handleOAuthAuthorize,
      },
      '/oauth/authorize/passkey': {
        method: 'POST',
        handler: handleOAuthAuthorizePasskey,
      },
      '/oauth/token': {
        method: 'POST',
        handler: handleOAuthToken,
      },
      '/oauth/revoke': {
        method: 'POST',
        handler: handleOAuthRevoke,
      },
    },
  };
}