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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
700 lines · 25.5 KB Raw History
// @pdsjs/core/oauth - OAuth 2.0 with DPoP for AT Protocol
// DPoP verification, consent page rendering, and metadata helpers

import {
  base64UrlDecode,
  base64UrlEncode,
  computeJwkThumbprint,
} from './crypto.js';
import { B64_SCRIPT, escapeHtml } from './html.js';
import { parseScopesForDisplay, permissionSetDef } from './scope.js';

// DPoP nonce rotation window (RFC 9449 §8). A nonce is valid for its own
// window and the following one, so clients get at least 5 minutes of reuse.
const DPOP_NONCE_WINDOW_MS = 5 * 60 * 1000;

/**
 * HMAC a nonce window counter with the server secret.
 * @param {string} secret
 * @param {number} window - Window counter (floor(time / window size))
 * @returns {Promise<string>} base64url nonce
 */
async function computeDpopNonce(secret, window) {
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const mac = await crypto.subtle.sign(
    'HMAC',
    key,
    new TextEncoder().encode(`dpop-nonce:${window}`),
  );
  return base64UrlEncode(new Uint8Array(mac));
}

/**
 * Mint the DPoP nonce for the current rotation window.
 * @param {string} secret - Server secret (jwtSecret)
 * @param {number} [now] - Current time in ms (defaults to Date.now())
 * @returns {Promise<string>}
 */
export async function mintDpopNonce(secret, now = Date.now()) {
  return computeDpopNonce(secret, Math.floor(now / DPOP_NONCE_WINDOW_MS));
}

/**
 * Check a DPoP nonce against the current and previous rotation windows.
 * @param {string} secret - Server secret (jwtSecret)
 * @param {string} nonce - Nonce claimed by the client
 * @param {number} [now] - Current time in ms (defaults to Date.now())
 * @returns {Promise<boolean>}
 */
export async function isValidDpopNonce(secret, nonce, now = Date.now()) {
  if (!nonce) return false;
  const window = Math.floor(now / DPOP_NONCE_WINDOW_MS);
  return (
    nonce === (await computeDpopNonce(secret, window)) ||
    nonce === (await computeDpopNonce(secret, window - 1))
  );
}

/**
 * @typedef {Object} DpopProofResult
 * @property {string} jkt - The JWK thumbprint of the DPoP key
 * @property {string} jti - The unique identifier from the DPoP proof
 * @property {number} iat - The issued-at timestamp from the DPoP proof
 * @property {Object} jwk - The public key JWK from the proof header
 * @property {string} [nonce] - The server nonce from the proof, if present
 */

/**
 * 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
 */
export 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?.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 valid = await crypto.subtle.verify(
    { name: 'ECDSA', hash: 'SHA-256' },
    publicKey,
    /** @type {BufferSource} */ (signature),
    /** @type {BufferSource} */ (signatureInput),
  );
  if (!valid) throw new Error('DPoP proof signature invalid');

  // Validate claims
  if (payload.htm !== method) throw new Error('DPoP htm mismatch');

  /** @param {string} u */
  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,
    iat: payload.iat,
    jwk: header.jwk,
    nonce: payload.nonce,
  };
}

/**
 * Generate OAuth Authorization Server metadata
 * @param {string} issuer - The issuer URL (e.g., https://pds.example.com)
 * @returns {Object} OAuth AS metadata object
 */
export function getAuthServerMetadata(issuer) {
  return {
    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', 'transition:generic', 'transition:email'],
    subject_types_supported: ['public'],
    response_types_supported: ['code'],
    response_modes_supported: ['query', 'fragment'],
    grant_types_supported: ['authorization_code', 'refresh_token'],
    code_challenge_methods_supported: ['S256'],
    // 'none' for public clients (PKCE + DPoP carry them); 'private_key_jwt' for
    // confidential clients (most real atproto apps), which authenticate with a
    // client_assertion JWT signed by a key in their published JWKS.
    token_endpoint_auth_methods_supported: ['none', 'private_key_jwt'],
    token_endpoint_auth_signing_alg_values_supported: ['ES256'],
    dpop_signing_alg_values_supported: ['ES256'],
    // atproto requires every authorization request to go through PAR (which this
    // server implements); the endpoint is advertised above.
    require_pushed_authorization_requests: true,
    authorization_response_iss_parameter_supported: true,
    client_id_metadata_document_supported: true,
    protected_resources: [issuer],
  };
}

/**
 * Generate OAuth Protected Resource metadata
 * @param {string} resource - The resource URL (e.g., https://pds.example.com)
 * @returns {Object} OAuth PR metadata object
 */
export function getProtectedResourceMetadata(resource) {
  return {
    resource,
    authorization_servers: [resource],
    bearer_methods_supported: ['header'],
    scopes_supported: ['atproto', 'transition:generic', 'transition:email'],
  };
}

/**
 * Render repo permissions as HTML table.
 * @param {Map<string, {create: boolean, update: boolean, delete: boolean}>} repoPermissions
 * @returns {string} HTML string
 */
function renderRepoTable(repoPermissions) {
  if (repoPermissions.size === 0) return '';

  let rows = '';
  for (const [collection, actions] of repoPermissions) {
    const displayCollection = collection === '*' ? '* (any)' : collection;
    rows += `<tr>
      <td>${escapeHtml(displayCollection)}</td>
      <td class="check">${actions.create ? '\u2713' : ''}</td>
      <td class="check">${actions.update ? '\u2713' : ''}</td>
      <td class="check">${actions.delete ? '\u2713' : ''}</td>
    </tr>`;
  }

  return `<div class="permissions-section">
    <div class="section-label">Repository permissions:</div>
    <table class="permissions-table">
      <thead><tr><th>Collection</th><th title="Create">C</th><th title="Update">U</th><th title="Delete">D</th></tr></thead>
      <tbody>${rows}</tbody>
    </table>
  </div>`;
}

/**
 * Render blob permissions as HTML list.
 * @param {string[]} blobPermissions
 * @returns {string} HTML string
 */
function renderBlobList(blobPermissions) {
  if (blobPermissions.length === 0) return '';

  const items = blobPermissions
    .map(
      (mime) =>
        `<li>${escapeHtml(mime === '*/*' ? 'All file types' : mime)}</li>`,
    )
    .join('');

  return `<div class="permissions-section">
    <div class="section-label">Upload permissions:</div>
    <ul class="blob-list">${items}</ul>
  </div>`;
}

/**
 * Render space permissions (permissioned data, proposal 0016) as HTML.
 * @param {Array<{spaceType: string, authority: string, skey: string, collections: string[]|null, actions: string[], manage: string[]}>} spacePermissions
 * @returns {string} HTML string
 */
function renderSpaceList(spacePermissions) {
  if (spacePermissions.length === 0) return '';

  const items = spacePermissions
    .map((grant) => {
      const type = grant.spaceType === '*' ? 'any space type' : grant.spaceType;
      const authority =
        grant.authority === 'self'
          ? 'your spaces'
          : grant.authority === '*'
            ? 'spaces under any account'
            : `spaces under ${grant.authority}`;
      const skey = grant.skey === '*' ? '' : ` (key: ${grant.skey})`;

      const actions = grant.actions
        .map((a) => (a === 'read_self' ? 'read own records' : a))
        .join(', ');
      const collections =
        grant.collections === null
          ? ''
          : grant.collections.includes('*')
            ? ' in any collection'
            : ` in ${grant.collections.join(', ')}`;
      const manage =
        grant.manage.length > 0
          ? `<br><small>Manage spaces: ${escapeHtml(grant.manage.join(', '))}</small>`
          : '';

      return `<li><b>${escapeHtml(type)}</b> — ${escapeHtml(authority)}${escapeHtml(skey)}<br>
        <small>${escapeHtml(actions)}${escapeHtml(collections)}</small>${manage}</li>`;
    })
    .join('');

  return `<div class="permissions-section">
    <div class="section-label">Private space permissions:</div>
    <ul class="blob-list">${items}</ul>
  </div>`;
}

/**
 * Render the permission sets a request includes, by the title and detail
 * their authors published, falling back to the bare NSID.
 * @param {Array<{nsid: string, aud: string|null}>} includeScopes
 * @param {Record<string, object>|null} permissionSets
 * @returns {string} HTML string
 */
function renderPermissionSetList(includeScopes, permissionSets) {
  if (includeScopes.length === 0) return '';
  const items = includeScopes
    .map(({ nsid }) => {
      const def = permissionSetDef(permissionSets?.[nsid]);
      const title =
        typeof def?.title === 'string' && def.title ? def.title : nsid;
      const detail = typeof def?.detail === 'string' ? def.detail : '';
      return `<li><b>${escapeHtml(title)}</b>${
        detail ? `<br><small>${escapeHtml(detail)}</small>` : ''
      }</li>`;
    })
    .join('');
  return `<div class="permissions-section">
    <div class="section-label">Permission sets:</div>
    <ul class="blob-list">${items}</ul>
  </div>`;
}

/**
 * Render full permissions display based on parsed scopes.
 * @param {ReturnType<typeof parseScopesForDisplay>} parsed
 * @param {Record<string, object>|null} [permissionSets]
 * @returns {string} HTML string
 */
function renderPermissionsHtml(parsed, permissionSets = null) {
  // Email access pairs with any other grant (classically transition:generic +
  // transition:email), so it is appended rather than returned on its own.
  const email = parsed.hasEmail
    ? `<div class="permissions-section">
      <div class="section-label">\u2709\ufe0f This app will be able to see your account email address.</div>
    </div>`
    : '';

  if (parsed.hasTransitionGeneric) {
    return `<div class="warning">\u26a0\ufe0f Full repository access requested<br>
      <small>This app can create, update, and delete any data in your repository.</small></div>${email}`;
  }

  const sets = renderPermissionSetList(parsed.includeScopes, permissionSets);

  if (
    parsed.repoPermissions.size === 0 &&
    parsed.blobPermissions.length === 0 &&
    parsed.spacePermissions.length === 0
  ) {
    return sets + email;
  }

  return (
    sets +
    renderRepoTable(parsed.repoPermissions) +
    renderBlobList(parsed.blobPermissions) +
    renderSpaceList(parsed.spacePermissions) +
    email
  );
}

/**
 * Render the OAuth consent page HTML.
 * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, loginHint?: string, error?: string, passkey?: {challenge: string, rpId: string}|null, permissionSets?: Record<string, object>|null }} params
 * @returns {string} HTML page content
 */
export function renderConsentPage({
  clientName,
  clientId,
  scope,
  requestUri,
  loginHint = '',
  error = '',
  passkey = null,
  permissionSets = null,
}) {
  const parsed = parseScopesForDisplay(scope);
  const isIdentityOnly =
    parsed.repoPermissions.size === 0 &&
    parsed.blobPermissions.length === 0 &&
    parsed.spacePermissions.length === 0 &&
    parsed.includeScopes.length === 0 &&
    !parsed.hasTransitionGeneric &&
    !parsed.hasEmail;

  return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Authorize</title>
<style>
*{box-sizing:border-box}
body{font-family:system-ui,sans-serif;max-width:400px;margin:40px auto;padding:20px;background:#09090b;color:#fafafa}
h2{color:#fff;margin-bottom:24px}
p{color:#a1a1aa;line-height:1.5}
b{color:#fff}
.error{color:#f87171;background:#1f1113;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #3f1d20}
label{display:block;margin:16px 0 6px;color:#a1a1aa;font-size:14px}
input[type="password"]{width:100%;padding:12px;background:#18181b;border:1px solid #2e2e34;border-radius:6px;color:#fff;font-size:16px}
input[type="password"]:focus{outline:none;border-color:#fafafa;box-shadow:0 0 0 2px rgba(59,130,246,0.35)}
.actions{display:flex;gap:12px;margin-top:24px}
button{flex:1;padding:12px 20px;border-radius:6px;font-size:16px;font-weight:500;cursor:pointer;transition:background 0.15s}
.deny{background:#18181b;color:#fafafa;border:1px solid #2e2e34}
.deny:hover{background:#26262b}
.approve{background:#fafafa;color:#18181b;border:none}
.approve:hover{background:#e4e4e7}
.permissions-section{margin:16px 0}
.section-label{color:#a1a1aa;font-size:13px;margin-bottom:8px}
.permissions-table{width:100%;border-collapse:collapse;font-size:13px}
.permissions-table th{color:#6b6b74;font-weight:normal;text-align:left;padding:4px 8px;border-bottom:1px solid #26262b}
.permissions-table th:not(:first-child){text-align:center;width:32px}
.permissions-table td{padding:4px 8px;border-bottom:1px solid #18181b}
.permissions-table td:not(:first-child){text-align:center}
.permissions-table .check{color:#4ade80}
.blob-list{margin:0;padding-left:20px;color:#fafafa;font-size:13px}
.blob-list li{margin:4px 0}
.warning{background:#292013;border:1px solid #4a3a1a;border-radius:6px;padding:12px;color:#fafafa;margin:16px 0}
.warning small{color:#d4b350;display:block;margin-top:4px}
.profile-card{display:flex;align-items:center;gap:12px;padding:16px;background:#18181b;border-radius:8px;margin-bottom:20px}
.profile-card.loading .avatar{background:#2e2e34;animation:pulse 1.5s infinite}
.profile-card .avatar{width:48px;height:48px;border-radius:50%;background:#2e2e34;flex-shrink:0}
.profile-card .avatar img{width:100%;height:100%;border-radius:50%;object-fit:cover}
.profile-card .info{min-width:0}
.profile-card .name{color:#fff;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.profile-card .handle{color:#6b6b74;font-size:14px}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}}
</style></head>
<body>
${
  loginHint
    ? `<div class="profile-card loading" id="profile-card">
<div class="avatar" id="profile-avatar"></div>
<div class="info"><div class="name" id="profile-name">Loading...</div>
<div class="handle" id="profile-handle">${escapeHtml(loginHint.startsWith('did:') ? loginHint : `@${loginHint}`)}</div></div>
</div>`
    : ''
}
<h2>Sign in to authorize</h2>
<p><b>${escapeHtml(clientName)}</b> ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}</p>
${renderPermissionsHtml(parsed, permissionSets)}
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="request_uri" value="${escapeHtml(requestUri)}">
<input type="hidden" name="client_id" value="${escapeHtml(clientId)}">
<label>Password</label><input type="password" name="password" required autofocus>
<div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button>
<button type="submit" name="action" value="approve" class="approve">Authorize</button></div>
</form>
${
  passkey
    ? `<p class="error" id="passkey-error" hidden></p>
<button type="button" class="deny" id="passkey-approve" style="width:100%;margin-top:12px">Authorize with a passkey</button>`
    : ''
}
${
  loginHint
    ? `<script>
(async()=>{
const card=document.getElementById('profile-card');
if(!card)return;
try{
const r=await fetch('https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor='+encodeURIComponent(${JSON.stringify(loginHint)}));
if(!r.ok)throw new Error();
const p=await r.json();
document.getElementById('profile-avatar').innerHTML=p.avatar?'<img src="'+p.avatar+'" alt="">':'';
document.getElementById('profile-name').textContent=p.displayName||p.handle;
document.getElementById('profile-handle').textContent='@'+p.handle;
card.classList.remove('loading');
}catch(e){card.classList.remove('loading')}
})();
</script>`
    : ''
}
${
  passkey
    ? `<script>
${B64_SCRIPT}
const perr = document.getElementById('passkey-error');
const pshow = (m) => { perr.textContent = m; perr.hidden = false; };
document.getElementById('passkey-approve').addEventListener('click', async () => {
  perr.hidden = true;
  if (!window.PublicKeyCredential) return pshow('This browser has no passkey support.');
  try {
    const assertion = await navigator.credentials.get({ publicKey: {
      challenge: unb64(${JSON.stringify(passkey?.challenge || '')}),
      rpId: ${JSON.stringify(passkey?.rpId || '')},
      userVerification: 'preferred',
      timeout: 120000,
    }});
    const response = await fetch('/oauth/authorize/passkey', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        request_uri: ${JSON.stringify(requestUri)},
        client_id: ${JSON.stringify(clientId)},
        credentialId: b64(assertion.rawId),
        authenticatorData: b64(assertion.response.authenticatorData),
        clientDataJSON: b64(assertion.response.clientDataJSON),
        signature: b64(assertion.response.signature),
      }),
    });
    const result = await response.json();
    if (!response.ok) return pshow(result.error || 'That passkey was refused.');
    location.href = result.redirect;
  } catch (e) {
    pshow(e && e.name === 'NotAllowedError' ? 'Cancelled.' : String(e && e.message || e));
  }
});
</script>`
    : ''
}
</body></html>`;
}

// ============================================================================
// Client Metadata Utilities
// ============================================================================

/**
 * @typedef {Object} ClientMetadata
 * @property {string} client_id
 * @property {string} [client_name]
 * @property {string[]} redirect_uris
 * @property {string[]} [grant_types]
 * @property {string[]} [response_types]
 * @property {string} [token_endpoint_auth_method]
 * @property {boolean} [dpop_bound_access_tokens]
 * @property {string} [scope]
 * @property {{keys: PublicJwk[]}} [jwks] - the client's inline key set
 * @property {string} [jwks_uri] - where the client publishes its key set
 */

/**
 * A public key a client publishes to verify its signed assertions.
 *
 * @typedef {Object} PublicJwk
 * @property {string} [kty]
 * @property {string} [crv]
 * @property {string} [x]
 * @property {string} [y]
 * @property {string} [kid]
 */

/**
 * Check if a client_id is a loopback address (localhost, 127.0.0.1, or [::1]).
 * @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;
  }
}

/**
 * Generate permissive client metadata for a loopback client.
 * @param {string} clientId - The loopback client_id
 * @returns {ClientMetadata & {grant_types: string[], response_types: string[]}} Generated client metadata with required grant_types
 */
export function getLoopbackClientMetadata(clientId) {
  // A loopback client has nowhere to host a metadata document, so it states its
  // registration in the client_id itself: `redirect_uri` and `scope` query
  // parameters, repeated for each redirect URI. Reading the client_id as its own
  // sole redirect URI instead refuses every such client whose callback is not
  // the bare origin — which is every one of them that has a callback path.
  //
  // A client_id carrying no such parameter keeps its old reading — itself as
  // its redirect URI — so this only ever admits a client that was refused.
  /** @type {string[]} */
  let redirectUris = [];
  let scope = 'atproto';
  try {
    const url = new URL(clientId);
    redirectUris = url.searchParams.getAll('redirect_uri');
    scope = url.searchParams.get('scope') || scope;
  } catch {
    // Not a URL at all; the caller already treated it as a loopback client, so
    // fall through to the defaults rather than failing here.
  }

  return {
    client_id: clientId,
    // What a user needs to know on the consent screen: this is an
    // unregistered app running on their own device, not a hosted service.
    client_name: 'An application on your device',
    redirect_uris: redirectUris.length > 0 ? redirectUris : [clientId],
    grant_types: ['authorization_code', 'refresh_token'],
    response_types: ['code'],
    token_endpoint_auth_method: 'none',
    dpop_bound_access_tokens: true,
    scope,
  };
}

/**
 * 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');
  }
}

/**
 * A client's published metadata document, fetched from its client_id URL.
 *
 * A loopback client gets a generated document rather than a fetch: it has
 * nowhere to host one.
 *
 * @param {string} clientId - the client_id, which is its metadata URL
 * @returns {Promise<ClientMetadata>}
 */
export async function fetchClientMetadata(clientId) {
  if (isLoopbackClient(clientId)) {
    return getLoopbackClientMetadata(clientId);
  }
  const response = await fetch(clientId, {
    headers: { Accept: 'application/json' },
  });
  if (!response.ok) {
    throw new Error(`Failed to fetch client metadata: ${response.status}`);
  }
  return /** @type {ClientMetadata} */ (await response.json());
}

/**
 * The public JWK a `kid` names in a client's key set, from the inline `jwks` or
 * the document at `jwks_uri`.
 *
 * @param {ClientMetadata|null|undefined} clientMetadata
 * @param {string} kid
 * @returns {Promise<PublicJwk|null>}
 */
export async function resolveClientJwk(clientMetadata, kid) {
  /** @type {PublicJwk[]|null} */
  let keys =
    clientMetadata?.jwks && Array.isArray(clientMetadata.jwks.keys)
      ? clientMetadata.jwks.keys
      : null;
  if (!keys && clientMetadata?.jwks_uri) {
    try {
      const res = await fetch(clientMetadata.jwks_uri, {
        headers: { Accept: 'application/json' },
      });
      if (res.ok) {
        const doc = /** @type {{keys?: PublicJwk[]}} */ (await res.json());
        if (Array.isArray(doc?.keys)) keys = doc.keys;
      }
    } catch {
      // Unreachable JWKS -> no key; the caller refuses the client.
    }
  }
  if (!keys) return null;
  return keys.find((k) => k.kid === kid) || null;
}

/**
 * Verify an ES256 (P-256) JWT signature against a public JWK.
 * @param {PublicJwk} jwk
 * @param {string} signingInput - `${headerB64}.${payloadB64}`
 * @param {string} sigB64 - base64url signature (raw r||s)
 * @returns {Promise<boolean>}
 */
export async function verifyEs256WithJwk(jwk, signingInput, sigB64) {
  const key = await crypto.subtle.importKey(
    'jwk',
    { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y },
    { name: 'ECDSA', namedCurve: 'P-256' },
    false,
    ['verify'],
  );
  return crypto.subtle.verify(
    { name: 'ECDSA', hash: 'SHA-256' },
    key,
    /** @type {BufferSource} */ (base64UrlDecode(sigB64)),
    /** @type {BufferSource} */ (new TextEncoder().encode(signingInput)),
  );
}