// @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} 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} */ 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} */ 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} 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} 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 += ` ${escapeHtml(displayCollection)} ${actions.create ? '\u2713' : ''} ${actions.update ? '\u2713' : ''} ${actions.delete ? '\u2713' : ''} `; } return `
${rows}
CollectionCUD
`; } /** * 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) => `
  • ${escapeHtml(mime === '*/*' ? 'All file types' : mime)}
  • `, ) .join(''); return `
      ${items}
    `; } /** * 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 ? `
    Manage spaces: ${escapeHtml(grant.manage.join(', '))}` : ''; return `
  • ${escapeHtml(type)} — ${escapeHtml(authority)}${escapeHtml(skey)}
    ${escapeHtml(actions)}${escapeHtml(collections)}${manage}
  • `; }) .join(''); return `
      ${items}
    `; } /** * 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|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 `
  • ${escapeHtml(title)}${ detail ? `
    ${escapeHtml(detail)}` : '' }
  • `; }) .join(''); return `
      ${items}
    `; } /** * Render full permissions display based on parsed scopes. * @param {ReturnType} parsed * @param {Record|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 ? `
    ` : ''; if (parsed.hasTransitionGeneric) { return `
    \u26a0\ufe0f Full repository access requested
    This app can create, update, and delete any data in your repository.
    ${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|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 ` Authorize ${ loginHint ? `
    Loading...
    ${escapeHtml(loginHint.startsWith('did:') ? loginHint : `@${loginHint}`)}
    ` : '' }

    Sign in to authorize

    ${escapeHtml(clientName)} ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}

    ${renderPermissionsHtml(parsed, permissionSets)} ${error ? `

    ${escapeHtml(error)}

    ` : ''}
    ${ passkey ? ` ` : '' } ${ loginHint ? `` : '' } ${ passkey ? `` : '' } `; } // ============================================================================ // 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} */ 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} */ 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} */ 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)), ); }