// @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} [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} [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} */ function langVariants(def, prop) { /** @type {Record} */ 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|null} [permissionSets] */ function serializeScopeDisplay(scope, permissionSets = null) { const parsed = parseScopesForDisplay(scope); /** @param {Map} 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} passkeys * @property {string} jwtSecret * @property {(candidate: unknown) => Promise} verifyAccountPassword - For the consent page's password approval * @property {() => Promise} 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} getDid * @property {() => Promise} getSigningKey * @property {() => Promise} getPublicKeyJwk * @property {(request: Request) => Promise} 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} */ 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} */ 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|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} */ 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|null>} */ async function resolveSpaceTypes(scope) { const getLexicon = lexiconResolver?.getLexicon?.bind(lexiconResolver); if (!getLexicon) return null; /** @type {Record} */ 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} params - the request body params * @param {string} issuer - this server's OAuth issuer URL * @returns {Promise} 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} */ 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} */ 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} */ 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} */ 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} 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[0]} params * @param {URL} url * @returns {Promise} */ 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} */ 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} */ 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} */ 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} */ 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} */ 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} */ 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} */ 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, }, }, }; }