chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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)),
);
}