chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/core/handlers/oauth - The authorization-server half of OAuth:
// server metadata, JWKS, PAR, the authorize and consent flow (password and
// passkey), the token grants, and revocation. The resource-server half (DPoP
// access-token verification on API requests) stays in pds.js with dispatch.
import {
base64UrlDecode,
base64UrlEncode,
sign,
signingKeyCurve,
} from '../crypto.js';
import { isSameOriginPost } from '../http.js';
import {
fetchClientMetadata,
getAuthServerMetadata,
getProtectedResourceMetadata,
isLoopbackClient,
isValidDpopNonce,
mintDpopNonce,
parseDpopProof,
renderConsentPage,
resolveClientJwk,
verifyEs256WithJwk,
} from '../oauth.js';
import { PASSKEY_COOKIE } from '../passkey.js';
import {
bareSpaceTypes,
expandPermissionSet,
expandScopeString,
expandSpaceScopes,
parseIncludeScope,
parseScopesForDisplay,
parseSpaceScope,
permissionSetDef,
spaceTypeCollections,
} from '../scope.js';
import { verifyAssertion } from '../webauthn.js';
/**
* @typedef {import('../oauth.js').DpopProofResult} DpopProofResult
*/
/**
* @typedef {Object} OAuthRequest
* @property {string} clientId - OAuth client ID
* @property {Object} [clientMetadata] - Client metadata
* @property {string} [clientMetadata.client_name] - Client name
* @property {string[]} [clientMetadata.redirect_uris] - Allowed redirect URIs
* @property {string} redirectUri - Redirect URI
* @property {string} scope - OAuth scope
* @property {string} [state] - OAuth state parameter
* @property {string} [responseMode] - Response mode (query/fragment)
* @property {string} [loginHint] - Login hint
* @property {string} codeChallenge - PKCE code challenge
* @property {string} codeChallengeMethod - PKCE method (S256)
* @property {string|null} dpopJkt - DPoP key thumbprint
* @property {string} [code] - Authorization code (after approval)
* @property {string} [did] - User DID (after approval)
* @property {string|null} [userAgent] - Device that consented (after approval)
* @property {string} [requestUri] - For code mapping lookup
* @property {Record<string, object>} [permissionSets] - Resolved permission-set
* lexicon documents for the request's include: scopes, keyed by NSID
* @property {string} [requestedScope] - The client's original scope string,
* kept when approval expands include: scopes into concrete grants
* @property {Record<string, string[]>} [spaceTypes] - The collections each
* space type declares, for the request's space: scopes that name none
*/
/**
* @typedef {Object} OAuthToken
* @property {string} did - User DID
* @property {string} clientId - OAuth client ID
* @property {string} scope - OAuth scope
* @property {string} dpopJkt - DPoP key thumbprint
* @property {number} createdAt - Token creation timestamp
* @property {number} [updatedAt] - Last authorization or refresh timestamp
* @property {string|null} [userAgent] - Device that opened the session
* @property {string} [rotatedTo] - On a spent refresh token: its replacement
* @property {string} [replaced] - The spent token this one replaced
*/
/**
* @typedef {Object} ClientMetadata
* @property {string} client_id - Client ID
* @property {string} [client_name] - Client display name
* @property {string[]} [redirect_uris] - Allowed redirect URIs
*/
/**
* @typedef {Object} AuthCodeGrantParams
* @property {string} [code]
* @property {string} [redirect_uri]
* @property {string} [client_id]
* @property {string} [code_verifier]
*/
/**
* @typedef {Object} RefreshGrantParams
* @property {string} [refresh_token]
* @property {string} [client_id]
*/
// RFC 7523 client authentication (private_key_jwt). Confidential OAuth clients
// prove their identity at PAR and the token endpoint with a short-lived JWT
// ("client_assertion") signed by a key in their published JWKS.
const CLIENT_ASSERTION_TYPE_JWT_BEARER =
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
const CLIENT_ASSERTION_MAX_AGE_S = 60;
const DAY_MS = 24 * 60 * 60 * 1000;
// How long an OAuth session lasts before the user must re-authorize, from its
// first authorization, and how long it lasts without a refresh. The values
// match the atproto reference AS. A public client has only the first limit.
const OAUTH_PUBLIC_SESSION_TTL_MS = 14 * DAY_MS;
const OAUTH_CONFIDENTIAL_SESSION_TTL_MS = 2 * 365 * DAY_MS;
const OAUTH_CONFIDENTIAL_IDLE_TTL_MS = 90 * DAY_MS;
/**
* The language-tagged variants of a permission-set text property, e.g.
* `title:fr` -> `{ fr: '...' }`.
* @param {object|null} def @param {string} prop
* @returns {Record<string, string>}
*/
function langVariants(def, prop) {
/** @type {Record<string, string>} */
const out = {};
for (const [key, value] of Object.entries(def ?? {})) {
if (key.startsWith(`${prop}:`) && typeof value === 'string') {
out[key.slice(prop.length + 1)] = value;
}
}
return out;
}
/**
* Parsed scopes as a JSON-serializable shape for the consent app's bootstrap,
* flattening the repo-permission Map the display parser returns. Include
* scopes are joined with their resolved permission sets so the page can show
* the author's title and detail alongside what the set concretely grants.
* @param {string} scope
* @param {Record<string, object>|null} [permissionSets]
*/
function serializeScopeDisplay(scope, permissionSets = null) {
const parsed = parseScopesForDisplay(scope);
/** @param {Map<string, {create: boolean, update: boolean, delete: boolean}>} repoPermissions */
const flattenRepo = (repoPermissions) =>
[...repoPermissions.entries()].map(([collection, actions]) => ({
collection,
...actions,
}));
const includes = parsed.includeScopes.map(({ nsid, aud }) => {
const doc = permissionSets?.[nsid] ?? null;
const def = permissionSetDef(doc);
const expanded = def ? (expandPermissionSet(nsid, doc, aud) ?? []) : [];
const inner = parseScopesForDisplay(expanded.join(' '));
return {
nsid,
aud,
title: typeof def?.title === 'string' ? def.title : null,
detail: typeof def?.detail === 'string' ? def.detail : null,
titleLangs: langVariants(def, 'title'),
detailLangs: langVariants(def, 'detail'),
repo: flattenRepo(inner.repoPermissions),
rpc: inner.rpcPermissions,
};
});
return {
scope,
hasAtproto: parsed.hasAtproto,
hasTransitionGeneric: parsed.hasTransitionGeneric,
hasEmail: parsed.hasEmail,
repo: flattenRepo(parsed.repoPermissions),
rpc: parsed.rpcPermissions,
includes,
blobs: parsed.blobPermissions,
spaces: parsed.spacePermissions,
};
}
/**
* @typedef {Object} OAuthContext
* @property {import('../ports.js').SharedStoragePort} sharedStorage
* @property {ReturnType<typeof import('../passkey.js').createPasskeyCeremony>} passkeys
* @property {string} jwtSecret
* @property {(candidate: unknown) => Promise<boolean>} verifyAccountPassword - For the consent page's password approval
* @property {() => Promise<boolean>} passwordConfigured
* @property {boolean} readOnly
* @property {boolean} spacesEnabled
* @property {boolean} hasAccountApp - Whether a mounted account application renders the consent page
* @property {import('../ports.js').LexiconResolverPort} [lexiconResolver]
* @property {() => Promise<string|null>} getDid
* @property {() => Promise<import('../crypto.js').SigningKey|null>} getSigningKey
* @property {() => Promise<Object|null>} getPublicKeyJwk
* @property {(request: Request) => Promise<any|Response>} readJsonBounded
* @property {(bootstrap?: Object|null, status?: number) => Response|null} accountAppResponse
* @property {() => Promise<{theme: string|null}>} readAccountUiState
* @property {() => Response} readOnlyError
*/
/**
* @param {OAuthContext} ctx
*/
export function createOAuthHandlers(ctx) {
const {
sharedStorage,
passkeys,
jwtSecret,
verifyAccountPassword,
passwordConfigured,
readOnly,
spacesEnabled,
hasAccountApp,
lexiconResolver,
getDid,
getSigningKey,
getPublicKeyJwk,
readJsonBounded,
accountAppResponse,
readAccountUiState,
readOnlyError,
} = ctx;
/**
* Require a valid DPoP nonce on an authorization-server request
* (RFC 9449 §8). Returns the use_dpop_nonce error response carrying a
* fresh nonce, or null when the proof's nonce is acceptable.
* @param {{nonce?: string}} dpop - Parsed DPoP proof
* @returns {Promise<Response|null>}
*/
async function requireDpopNonce(dpop) {
if (await isValidDpopNonce(jwtSecret, dpop.nonce || '')) return null;
return Response.json(
{
error: 'use_dpop_nonce',
error_description: 'Authorization server requires nonce in DPoP proof',
},
{
status: 400,
headers: { 'DPoP-Nonce': await mintDpopNonce(jwtSecret) },
},
);
}
/**
* /.well-known/oauth-authorization-server
* @param {Request} _request
* @param {URL} url
* @returns {Response}
*/
function handleOAuthAuthServerMetadata(_request, url) {
const issuer = `${url.protocol}//${url.host}`;
return Response.json(getAuthServerMetadata(issuer));
}
/**
* /.well-known/oauth-protected-resource
* @param {Request} _request
* @param {URL} url
* @returns {Response}
*/
function handleOAuthProtectedResource(_request, url) {
const resource = `${url.protocol}//${url.host}`;
return Response.json(getProtectedResourceMetadata(resource));
}
/**
* /oauth/jwks
* @returns {Promise<Response>}
*/
async function handleOAuthJwks() {
const publicKeyJwk = await getPublicKeyJwk();
if (!publicKeyJwk) {
return Response.json(
{ error: 'ServerError', message: 'No signing key configured' },
{ status: 500 },
);
}
const alg =
/** @type {{crv?: string}} */ (publicKeyJwk).crv === 'secp256k1'
? 'ES256K'
: 'ES256';
return Response.json({
keys: [{ ...publicKeyJwk, kid: 'pds-oauth-key', use: 'sig', alg }],
});
}
/**
* Validate OAuth authorization parameters
* @param {Object} params
* @param {string} [params.clientId]
* @param {string|null} [params.redirectUri]
* @param {string|null} [params.responseType]
* @param {string|null} [params.codeChallenge]
* @param {string|null} [params.codeChallengeMethod]
* @returns {Promise<{error: Response} | {clientMetadata: ClientMetadata}>}
*/
async function validateAuthorizationParameters({
clientId,
redirectUri,
responseType,
codeChallenge,
codeChallengeMethod,
}) {
if (!clientId) {
return {
error: Response.json(
{ error: 'invalid_request', error_description: 'client_id required' },
{ status: 400 },
),
};
}
if (!redirectUri) {
return {
error: Response.json(
{
error: 'invalid_request',
error_description: 'redirect_uri required',
},
{ status: 400 },
),
};
}
if (responseType !== 'code') {
return {
error: Response.json(
{
error: 'unsupported_response_type',
error_description: 'response_type must be code',
},
{ status: 400 },
),
};
}
if (!codeChallenge || codeChallengeMethod !== 'S256') {
return {
error: Response.json(
{
error: 'invalid_request',
error_description: 'PKCE with S256 required',
},
{ status: 400 },
),
};
}
let clientMetadata;
try {
clientMetadata = await fetchClientMetadata(clientId);
} catch (err) {
return {
error: Response.json(
{ error: 'invalid_client', error_description: err.message },
{ status: 400 },
),
};
}
// Validate redirect_uri
const isLoopback = isLoopbackClient(clientId);
const redirectUriValid = clientMetadata.redirect_uris?.some((uri) => {
if (isLoopback) {
// RFC 8252 7.3: a native app listens on whatever port it gets, so a
// loopback redirect matches any port.
try {
const registered = new URL(uri);
const requested = new URL(redirectUri);
return (
registered.protocol === requested.protocol &&
registered.hostname === requested.hostname
);
} catch {
return false;
}
}
return uri === redirectUri;
});
if (!redirectUriValid) {
return {
error: Response.json(
{
error: 'invalid_request',
error_description: 'redirect_uri not registered for client',
},
{ status: 400 },
),
};
}
return { clientMetadata };
}
/**
* Refuse scope grants for capabilities this server will not serve. With
* spaces disabled the space endpoints do not exist, so a space: grant would
* be a dead promise on the consent screen. Applies to both the PAR and the
* direct authorization paths.
* @param {string} scope - Space-separated scope string
* @returns {Response|null} An invalid_scope response, or null to proceed
*/
function rejectUnservableScopes(scope) {
if (spacesEnabled) return null;
const hasSpaceGrant = scope
.split(' ')
.some((/** @type {string} */ sc) => parseSpaceScope(sc) !== null);
if (!hasSpaceGrant) return null;
return Response.json(
{
error: 'invalid_scope',
error_description: 'space: scopes are not supported by this server',
},
{ status: 400 },
);
}
/**
* Resolve every include: scope's permission-set lexicon so consent can show
* what the set grants and approval can expand it. A request whose include:
* cannot be resolved to a permission set is refused outright — the
* alternative is a consent screen that cannot say what it is asking for.
* Applies to both the PAR and the direct authorization paths.
* @param {string} scope - Space-separated scope string
* @returns {Promise<{ permissionSets: Record<string, object>|null } | { error: Response }>}
*/
async function resolvePermissionSets(scope) {
const nsids = new Set();
for (const s of scope.split(' ')) {
const include = parseIncludeScope(s);
if (include) nsids.add(include.nsid);
}
if (nsids.size === 0) return { permissionSets: null };
/** @param {string} description @returns {{ error: Response }} */
const invalid = (description) => ({
error: Response.json(
{ error: 'invalid_scope', error_description: description },
{ status: 400 },
),
});
const getLexicon = lexiconResolver?.getLexicon?.bind(lexiconResolver);
if (!getLexicon) {
return invalid('include: scopes are not supported by this server');
}
/** @type {Record<string, object>} */
const permissionSets = {};
for (const nsid of nsids) {
let doc = null;
try {
doc = await getLexicon(nsid);
} catch {
// Resolution failure and absence refuse the request identically.
}
if (!doc || !permissionSetDef(doc)) {
return invalid(`include:${nsid} does not name a known permission set`);
}
permissionSets[nsid] = doc;
}
return { permissionSets };
}
/**
* Resolve the lexicon of each space type that a space: scope names without
* a collection. Approval writes the declared collections into the scope.
* A type that does not resolve to a space is left out, and its scope is
* granted as asked.
* @param {string} scope - Space-separated scope string
* @returns {Promise<Record<string, string[]>|null>}
*/
async function resolveSpaceTypes(scope) {
const getLexicon = lexiconResolver?.getLexicon?.bind(lexiconResolver);
if (!getLexicon) return null;
/** @type {Record<string, string[]>} */
const spaceTypes = {};
for (const type of bareSpaceTypes(scope)) {
let doc = null;
try {
doc = await getLexicon(type);
} catch {
// A type that fails to resolve is granted as asked.
}
const collections = spaceTypeCollections(doc);
if (collections) spaceTypes[type] = collections;
}
return Object.keys(spaceTypes).length > 0 ? spaceTypes : null;
}
/**
* Authenticate an OAuth client for the token/PAR endpoints, per its
* `token_endpoint_auth_method`. Public clients (`none`) need nothing here —
* PKCE and DPoP carry them. A confidential client (`private_key_jwt`) must
* present a `client_assertion` JWT signed by a key in its published JWKS,
* verified per RFC 7523: `iss`/`sub` are the client_id, `aud` names this
* server, it is unexpired and recent, and its `jti` has not been seen.
* @param {any} clientMetadata - the client's fetched metadata
* @param {Record<string, any>} params - the request body params
* @param {string} issuer - this server's OAuth issuer URL
* @returns {Promise<Response|null>} an error Response, or null when authenticated
*/
async function authenticateClient(clientMetadata, params, issuer) {
const method = clientMetadata?.token_endpoint_auth_method || 'none';
/** @param {string} error @param {string} description */
const fail = (error, description) =>
Response.json({ error, error_description: description }, { status: 400 });
if (method === 'none') return null;
if (method !== 'private_key_jwt') {
return fail(
'invalid_client',
`Unsupported token_endpoint_auth_method "${method}"`,
);
}
const assertion = params.client_assertion;
if (!assertion) {
return fail(
'invalid_client',
'client_assertion required for private_key_jwt',
);
}
if (params.client_assertion_type !== CLIENT_ASSERTION_TYPE_JWT_BEARER) {
return fail(
'invalid_request',
`Unsupported client_assertion_type "${params.client_assertion_type}"`,
);
}
const parts = String(assertion).split('.');
if (parts.length !== 3)
return fail('invalid_client', 'Malformed client_assertion');
let header;
let payload;
try {
header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0])));
payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1])));
} catch {
return fail('invalid_client', 'Malformed client_assertion');
}
if (header.alg !== 'ES256') {
return fail('invalid_client', 'client_assertion alg must be ES256');
}
if (!header.kid) {
return fail('invalid_client', '"kid" required in client_assertion');
}
const clientId = clientMetadata.client_id;
const now = Math.floor(Date.now() / 1000);
// RFC 7523 §3: for client auth, iss and sub both are the client_id.
if (payload.iss !== clientId || payload.sub !== clientId) {
return fail(
'invalid_client',
'client_assertion "iss"/"sub" must be the client_id',
);
}
// aud must identify this authorization server (its issuer or an endpoint
// under it). The trailing slash stops "https://me" matching "https://me.evil".
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (
!aud.some(
(/** @type {string} */ a) =>
a === issuer || (typeof a === 'string' && a.startsWith(`${issuer}/`)),
)
) {
return fail(
'invalid_client',
'client_assertion "aud" must be this server',
);
}
if (typeof payload.exp === 'number' && payload.exp <= now) {
return fail('invalid_client', 'client_assertion has expired');
}
// Bound the age even when "exp" is absent.
if (
typeof payload.iat !== 'number' ||
payload.iat < now - CLIENT_ASSERTION_MAX_AGE_S
) {
return fail(
'invalid_client',
'client_assertion "iat" missing or too old',
);
}
if (!payload.jti || typeof payload.jti !== 'string') {
return fail('invalid_client', '"jti" required in client_assertion');
}
const jwk = await resolveClientJwk(clientMetadata, header.kid);
if (!jwk) {
return fail('invalid_client', 'No matching key in the client JWKS');
}
let validSig = false;
try {
validSig = await verifyEs256WithJwk(
jwk,
`${parts[0]}.${parts[1]}`,
parts[2],
);
} catch {
validSig = false;
}
if (!validSig) {
return fail('invalid_client', 'client_assertion signature is invalid');
}
// Replay protection for the assertion's lifetime, in the same store DPoP
// jtis use (namespaced so the two cannot collide).
const jtiExpiry =
typeof payload.exp === 'number'
? payload.exp * 1000
: Date.now() + CLIENT_ASSERTION_MAX_AGE_S * 1000;
const fresh = await sharedStorage.checkAndStoreDpopJti(
`client_assertion:${payload.jti}`,
jtiExpiry,
);
if (!fresh) {
return fail('invalid_client', 'client_assertion replay detected');
}
return null;
}
/**
* /oauth/par - Pushed Authorization Request
* @param {Request} request
* @param {URL} url
* @returns {Promise<Response>}
*/
async function handleOAuthPar(request, url) {
if (readOnly) return readOnlyError();
const issuer = `${url.protocol}//${url.host}`;
// Parse the body before any early return: responding without draining
// the request stream raises an uncaught error on Workers runtimes
const parContentType = request.headers.get('content-type') || '';
let data;
if (parContentType.includes('application/json')) {
data = await request.json();
} else {
const parText = await request.text();
data = Object.fromEntries(new URLSearchParams(parText));
}
// RFC 9449 §10.1: a pushed request names its DPoP key with a proof or
// with the dpop_jkt parameter. A client that sends neither binds the key
// at the token endpoint, which still requires a proof.
const dpopHeader = request.headers.get('DPoP');
let dpop = null;
if (dpopHeader) {
try {
dpop = await parseDpopProof(dpopHeader, 'POST', `${issuer}/oauth/par`);
} catch (err) {
return Response.json(
{ error: 'invalid_dpop_proof', error_description: err.message },
{ status: 400 },
);
}
const nonceError = await requireDpopNonce(dpop);
if (nonceError) return nonceError;
// Check DPoP replay
const fresh = await sharedStorage.checkAndStoreDpopJti(
dpop.jti,
Date.now() + 300000,
);
if (!fresh) {
return Response.json(
{
error: 'invalid_dpop_proof',
message: 'DPoP proof replay detected',
},
{ status: 400 },
);
}
if (data.dpop_jkt && data.dpop_jkt !== dpop.jkt) {
return Response.json(
{
error: 'invalid_dpop_proof',
error_description: 'dpop_jkt does not match the DPoP proof',
},
{ status: 400 },
);
}
}
const dpopJkt = dpop ? dpop.jkt : data.dpop_jkt || null;
const validationResult = await validateAuthorizationParameters({
clientId: data.client_id,
redirectUri: data.redirect_uri,
responseType: data.response_type,
codeChallenge: data.code_challenge,
codeChallengeMethod: data.code_challenge_method,
});
if ('error' in validationResult) return validationResult.error;
// A confidential client must authenticate the pushed request.
const clientAuthError = await authenticateClient(
validationResult.clientMetadata,
data,
issuer,
);
if (clientAuthError) return clientAuthError;
const scopeError = rejectUnservableScopes(data.scope || 'atproto');
if (scopeError) return scopeError;
const setsResult = await resolvePermissionSets(data.scope || 'atproto');
if ('error' in setsResult) return setsResult.error;
const spaceTypes = await resolveSpaceTypes(data.scope || 'atproto');
const requestId = crypto.randomUUID();
const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
const expiresIn = 600;
const expiresAt = Date.now() + expiresIn * 1000;
await sharedStorage.putOAuthRequest(
requestUri,
{
clientId: data.client_id,
clientMetadata: validationResult.clientMetadata,
redirectUri: data.redirect_uri,
scope: data.scope || 'atproto',
...(setsResult.permissionSets && {
permissionSets: setsResult.permissionSets,
}),
...(spaceTypes && { spaceTypes }),
state: data.state,
responseMode: data.response_mode,
loginHint: data.login_hint,
codeChallenge: data.code_challenge,
codeChallengeMethod: data.code_challenge_method,
dpopJkt,
},
expiresAt,
);
return Response.json(
{ request_uri: requestUri, expires_in: expiresIn },
{
// RFC 9126 §2.2: a successful PAR response is 201 Created. Clients
// such as indigo treat any other status as a failure.
status: 201,
headers: { 'DPoP-Nonce': await mintDpopNonce(jwtSecret) },
},
);
}
/**
* /oauth/authorize - Authorization endpoint
* @param {Request} request
* @param {URL} url
* @returns {Promise<Response>}
*/
async function handleOAuthAuthorize(request, url) {
if (request.method === 'GET') {
return handleOAuthAuthorizeGet(url);
} else if (request.method === 'POST') {
return handleOAuthAuthorizePost(request, url);
}
return Response.json({ error: 'MethodNotAllowed' }, { status: 405 });
}
/**
* GET /oauth/authorize - Display consent page
* @param {URL} url
* @returns {Promise<Response>}
*/
async function handleOAuthAuthorizeGet(url) {
const requestUri = url.searchParams.get('request_uri');
const clientId = url.searchParams.get('client_id');
if (requestUri) {
if (!clientId) {
return new Response('Missing client_id parameter', { status: 400 });
}
const authRequest = /** @type {OAuthRequest|null} */ (
await sharedStorage.getOAuthRequest(requestUri)
);
if (!authRequest) {
return new Response('Request not found or expired', { status: 400 });
}
if (authRequest.clientId !== clientId) {
return new Response('Client mismatch', { status: 400 });
}
if (authRequest.code) {
return new Response('Request already used', { status: 400 });
}
return consentResponse(
{
clientName: authRequest.clientMetadata?.client_name || clientId,
clientId,
scope: authRequest.scope || 'atproto',
permissionSets: authRequest.permissionSets,
requestUri,
loginHint: authRequest.loginHint || '',
},
url,
);
}
// Direct authorization flow
if (!clientId) {
return new Response('Missing client_id parameter', { status: 400 });
}
const validationResult = await validateAuthorizationParameters({
clientId,
redirectUri: url.searchParams.get('redirect_uri'),
responseType: url.searchParams.get('response_type'),
codeChallenge: url.searchParams.get('code_challenge'),
codeChallengeMethod: url.searchParams.get('code_challenge_method'),
});
if ('error' in validationResult) return validationResult.error;
// Same rule as PAR: never accept a space: grant this server will not
// serve. This is the direct (PAR-less) authorization path.
const scopeError = rejectUnservableScopes(
url.searchParams.get('scope') || 'atproto',
);
if (scopeError) return scopeError;
const setsResult = await resolvePermissionSets(
url.searchParams.get('scope') || 'atproto',
);
if ('error' in setsResult) return setsResult.error;
const spaceTypes = await resolveSpaceTypes(
url.searchParams.get('scope') || 'atproto',
);
const requestId = crypto.randomUUID();
const newRequestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
const expiresAt = Date.now() + 600000;
await sharedStorage.putOAuthRequest(
newRequestUri,
{
clientId,
clientMetadata: validationResult.clientMetadata,
redirectUri: url.searchParams.get('redirect_uri'),
scope: url.searchParams.get('scope') || 'atproto',
...(setsResult.permissionSets && {
permissionSets: setsResult.permissionSets,
}),
...(spaceTypes && { spaceTypes }),
state: url.searchParams.get('state'),
responseMode: url.searchParams.get('response_mode'),
loginHint: url.searchParams.get('login_hint'),
codeChallenge: url.searchParams.get('code_challenge'),
codeChallengeMethod: url.searchParams.get('code_challenge_method'),
dpopJkt: null,
},
expiresAt,
);
return consentResponse(
{
clientName: validationResult.clientMetadata.client_name || clientId,
clientId,
scope: url.searchParams.get('scope') || 'atproto',
permissionSets: setsResult.permissionSets ?? undefined,
requestUri: newRequestUri,
loginHint: url.searchParams.get('login_hint') || '',
},
url,
);
}
/**
* POST /oauth/authorize - Process user approval/denial
* @param {Request} request
* @param {URL} url
* @returns {Promise<Response>}
*/
async function handleOAuthAuthorizePost(request, url) {
const issuer = `${url.protocol}//${url.host}`;
const body = await request.text();
const params = new URLSearchParams(body);
const requestUri = params.get('request_uri');
const clientId = params.get('client_id');
const password = params.get('password');
const action = params.get('action');
// The account app asks for JSON so it can show its own redirect screen
// while the client's page loads. A plain form post gets the 302.
const wantsJson = (request.headers.get('accept') || '').includes(
'application/json',
);
if (!requestUri || !clientId) {
return authorizeError('Missing request_uri or client_id', wantsJson);
}
const authRequest = /** @type {OAuthRequest|null} */ (
await sharedStorage.getOAuthRequest(requestUri)
);
if (!authRequest) {
return authorizeError('Request not found', wantsJson);
}
if (authRequest.clientId !== clientId) {
return authorizeError('Client mismatch', wantsJson);
}
if (action === 'deny') {
await sharedStorage.deleteOAuthRequest(requestUri);
const errorUrl = new URL(authRequest.redirectUri);
errorUrl.searchParams.set('error', 'access_denied');
if (authRequest.state)
errorUrl.searchParams.set('state', authRequest.state);
errorUrl.searchParams.set('iss', issuer);
return authorizeRedirect(errorUrl.toString(), wantsJson);
}
// Verify password
if (
(await passwordConfigured()) &&
!(await verifyAccountPassword(password))
) {
if (wantsJson) {
return Response.json({ error: 'Invalid password' }, { status: 401 });
}
return consentResponse(
{
clientName: authRequest.clientMetadata?.client_name || clientId,
clientId,
scope: authRequest.scope || 'atproto',
permissionSets: authRequest.permissionSets,
requestUri,
loginHint: authRequest.loginHint || '',
error: 'Invalid password',
},
url,
);
}
const redirect = await issueAuthorizationCode(
authRequest,
requestUri,
issuer,
(request.headers.get('user-agent') || '').slice(0, 200) || null,
);
if (!redirect) {
return authorizeError('PDS not initialized', wantsJson);
}
return authorizeRedirect(redirect, wantsJson);
}
/**
* A refused authorization: JSON the account app shows in place, or the text
* the browser lands on.
* @param {string} message
* @param {boolean} wantsJson
* @returns {Response}
*/
function authorizeError(message, wantsJson) {
return wantsJson
? Response.json({ error: message }, { status: 400 })
: new Response(message, { status: 400 });
}
/**
* The end of an authorization, approved or denied: a browser following the
* form post gets the 302, a fetch gets the same URL to navigate to itself.
* @param {string} redirect
* @param {boolean} wantsJson
* @returns {Response}
*/
function authorizeRedirect(redirect, wantsJson) {
if (!wantsJson) return Response.redirect(redirect, 302);
return Response.json(
{ redirect },
{ headers: { 'Cache-Control': 'no-store' } },
);
}
/**
* Approve a request: mint its code and build the redirect back to the
* client. One path for every way consent can be given, so a passkey
* approval issues exactly what a password approval does.
* @param {OAuthRequest} authRequest
* @param {string} requestUri
* @param {string} issuer
* @param {string|null} [userAgent] - the device consenting, captured here
* rather than at the token endpoint (a confidential client redeems the code
* from its own server, so the token request is not the user's device)
* @returns {Promise<string|null>} Redirect URL, or null when uninitialized
*/
async function issueAuthorizationCode(
authRequest,
requestUri,
issuer,
userAgent = null,
) {
const code = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
const did = await getDid();
if (!did) return null;
// Approval freezes include: grants: the permission sets shown on the
// consent screen expand into the concrete scopes they granted at that
// moment, so tokens and enforcement never depend on a lexicon that may
// change or vanish later. A set's updates take effect on re-consent.
if (authRequest.permissionSets) {
authRequest.requestedScope = authRequest.scope;
authRequest.scope = expandScopeString(
authRequest.scope || 'atproto',
authRequest.permissionSets,
);
}
if (authRequest.spaceTypes) {
authRequest.requestedScope ??= authRequest.scope;
authRequest.scope = expandSpaceScopes(
authRequest.scope || 'atproto',
authRequest.spaceTypes,
);
}
// Update request with code
authRequest.code = code;
authRequest.did = did;
if (userAgent) authRequest.userAgent = userAgent;
await sharedStorage.putOAuthRequest(
requestUri,
authRequest,
Date.now() + 600000,
);
// Store code->requestUri mapping for token exchange lookup
await sharedStorage.putOAuthRequest(
`code:${code}`,
{ requestUri },
Date.now() + 600000,
);
const successUrl = new URL(authRequest.redirectUri);
if (authRequest.responseMode === 'fragment') {
const fragParams = new URLSearchParams();
fragParams.set('code', code);
if (authRequest.state) fragParams.set('state', authRequest.state);
fragParams.set('iss', issuer);
successUrl.hash = fragParams.toString();
} else {
successUrl.searchParams.set('code', code);
if (authRequest.state)
successUrl.searchParams.set('state', authRequest.state);
successUrl.searchParams.set('iss', issuer);
}
return successUrl.toString();
}
/**
* A consent response: the page, and when this server can check a passkey,
* the ceremony whose challenge the page's approve button will answer.
* @param {Parameters<typeof renderConsentPage>[0]} params
* @param {URL} url
* @returns {Promise<Response>}
*/
async function consentResponse(params, url) {
let passkey = null;
let cookie = null;
if (passkeys.passkeysAvailable()) {
const ceremony = await passkeys.startPasskeyCeremony(
'oauth-consent',
url,
'/oauth',
);
passkey = {
challenge: ceremony.challenge,
rpId: passkeys.passkeyRpId(url),
};
cookie = ceremony.cookie;
}
// The account app renders consent client-side from the same components as
// the console. It POSTs the same fields to the same endpoints, so the flow
// is unchanged; only the presentation moves.
if (hasAccountApp) {
// Non-null: accountAppResponse only returns null when accountApp is unset.
const response = /** @type {Response} */ (
accountAppResponse({
mode: 'consent',
theme: (await readAccountUiState()).theme,
clientName: params.clientName,
clientId: params.clientId,
requestUri: params.requestUri,
loginHint: params.loginHint || '',
error: params.error || '',
permissions: serializeScopeDisplay(
params.scope || 'atproto',
params.permissionSets ?? null,
),
passkey,
})
);
if (cookie) response.headers.set('Set-Cookie', cookie);
return response;
}
/** @type {Record<string, string>} */
const headers = { 'Content-Type': 'text/html; charset=utf-8' };
if (cookie) headers['Set-Cookie'] = cookie;
return new Response(renderConsentPage({ ...params, passkey }), { headers });
}
/**
* POST /oauth/authorize/passkey - Approve a request with a passkey
*
* The same consent the password field gives, proven by an assertion
* instead. The challenge rides the cookie the consent page set, typed
* 'oauth-consent' so a sign-in challenge cannot be spent here.
* @param {Request} request
* @param {URL} url
* @returns {Promise<Response>}
*/
async function handleOAuthAuthorizePasskey(request, url) {
const body = await readJsonBounded(request);
if (body instanceof Response) return body;
if (!isSameOriginPost(request, url)) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
if (!sharedStorage.getPasskey) {
return Response.json(
{ error: 'This server does not support passkeys' },
{ status: 400 },
);
}
const challenge = await passkeys.readPasskeyChallenge(
request,
'oauth-consent',
);
if (!challenge) {
return Response.json(
{ error: 'That authorization expired. Reload and try again.' },
{ status: 400 },
);
}
const requestUri = body.request_uri || '';
const authRequest = /** @type {OAuthRequest|null} */ (
await sharedStorage.getOAuthRequest(requestUri)
);
if (!authRequest || authRequest.clientId !== body.client_id) {
return Response.json(
{ error: 'Request not found or expired' },
{ status: 400 },
);
}
if (authRequest.code) {
return Response.json({ error: 'Request already used' }, { status: 400 });
}
const did = await getDid();
const passkey = await sharedStorage.getPasskey(body.credentialId || '');
if (!passkey || !did || passkey.did !== did) {
return Response.json(
{ error: 'That passkey is not registered here.' },
{ status: 401 },
);
}
let result;
try {
result = await verifyAssertion({
publicKey: passkey.publicKey,
algorithm: passkey.algorithm,
authenticatorData: body.authenticatorData,
clientDataJSON: body.clientDataJSON,
signature: body.signature,
expectedChallenge: challenge,
expectedOrigin: `${url.protocol}//${url.host}`,
rpId: passkeys.passkeyRpId(url),
});
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : 'Authorization failed' },
{ status: 401 },
);
}
if (
result.signCount > 0 &&
passkey.signCount > 0 &&
result.signCount <= passkey.signCount
) {
return Response.json(
{ error: 'That passkey looks cloned and was refused.' },
{ status: 401 },
);
}
if (sharedStorage.touchPasskey) {
await sharedStorage.touchPasskey(
passkey.credentialId,
result.signCount,
new Date().toISOString(),
);
}
const redirect = await issueAuthorizationCode(
authRequest,
requestUri,
`${url.protocol}//${url.host}`,
(request.headers.get('user-agent') || '').slice(0, 200) || null,
);
if (!redirect) {
return Response.json({ error: 'PDS not initialized' }, { status: 400 });
}
// The challenge is spent with the approval it authorized
return new Response(JSON.stringify({ redirect }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Set-Cookie': [
`${PASSKEY_COOKIE}=`,
'Path=/oauth',
'HttpOnly',
'SameSite=Lax',
'Max-Age=0',
url.protocol === 'https:' ? 'Secure' : null,
]
.filter(Boolean)
.join('; '),
},
});
}
/**
* /oauth/token - Token endpoint
* @param {Request} request
* @param {URL} url
* @returns {Promise<Response>}
*/
async function handleOAuthToken(request, url) {
if (readOnly) return readOnlyError();
const issuer = `${url.protocol}//${url.host}`;
// Parse the body before any early return: responding without draining
// the request stream raises an uncaught error on Workers runtimes
const tokenContentType = request.headers.get('content-type') || '';
let params;
if (tokenContentType.includes('application/json')) {
params = await request.json();
} else {
const tokenText = await request.text();
params = Object.fromEntries(new URLSearchParams(tokenText));
}
// Validate DPoP
const dpopHeader = request.headers.get('DPoP');
if (!dpopHeader) {
return Response.json(
{
error: 'invalid_dpop_proof',
error_description: 'DPoP proof required',
},
{ status: 400 },
);
}
let dpop;
try {
dpop = await parseDpopProof(dpopHeader, 'POST', `${issuer}/oauth/token`);
} catch (err) {
return Response.json(
{ error: 'invalid_dpop_proof', error_description: err.message },
{ status: 400 },
);
}
const nonceError = await requireDpopNonce(dpop);
if (nonceError) return nonceError;
// Check DPoP replay
const fresh = await sharedStorage.checkAndStoreDpopJti(
dpop.jti,
Date.now() + 300000,
);
if (!fresh) {
return Response.json(
{ error: 'invalid_dpop_proof', message: 'DPoP proof replay detected' },
{ status: 400 },
);
}
const grantType = params.grant_type;
const userAgent =
(request.headers.get('user-agent') || '').slice(0, 200) || null;
if (grantType === 'authorization_code') {
return handleAuthCodeGrant(params, dpop, issuer, userAgent);
} else if (grantType === 'refresh_token') {
return handleRefreshGrant(params, dpop, issuer);
}
return Response.json({ error: 'unsupported_grant_type' }, { status: 400 });
}
/**
* Handle authorization_code grant
* @param {AuthCodeGrantParams} params
* @param {DpopProofResult} dpop
* @param {string} issuer
* @param {string|null} [userAgent] - the device that opened the session
* @returns {Promise<Response>}
*/
async function handleAuthCodeGrant(params, dpop, issuer, userAgent = null) {
const { code, redirect_uri, client_id, code_verifier } = params;
if (!code || !redirect_uri || !client_id || !code_verifier) {
return Response.json(
{
error: 'invalid_request',
error_description: 'Missing required parameters',
},
{ status: 400 },
);
}
// Look up code → requestUri mapping
const codeMapping = /** @type {OAuthRequest|null} */ (
await sharedStorage.getOAuthRequest(`code:${code}`)
);
if (!codeMapping) {
return Response.json(
{
error: 'invalid_grant',
error_description: 'Invalid authorization code',
},
{ status: 400 },
);
}
// Look up the full auth request
if (!codeMapping.requestUri) {
return Response.json(
{ error: 'invalid_grant', error_description: 'Invalid code mapping' },
{ status: 400 },
);
}
const authRequest = /** @type {OAuthRequest|null} */ (
await sharedStorage.getOAuthRequest(codeMapping.requestUri)
);
if (!authRequest || authRequest.code !== code) {
return Response.json(
{
error: 'invalid_grant',
error_description: 'Invalid authorization code',
},
{ status: 400 },
);
}
// Validate redirect_uri matches
if (authRequest.redirectUri !== redirect_uri) {
return Response.json(
{ error: 'invalid_grant', error_description: 'Redirect URI mismatch' },
{ status: 400 },
);
}
// Validate client_id matches
if (authRequest.clientId !== client_id) {
return Response.json(
{ error: 'invalid_grant', error_description: 'Client ID mismatch' },
{ status: 400 },
);
}
// Authenticate the client (a confidential client presents a client_assertion
// here too). The client metadata was captured and stored at PAR time.
const clientAuthError = await authenticateClient(
authRequest.clientMetadata,
params,
issuer,
);
if (clientAuthError) return clientAuthError;
// Validate PKCE: code_verifier against stored code_challenge
const challengeBuffer = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(code_verifier),
);
const computedChallenge = base64UrlEncode(new Uint8Array(challengeBuffer));
if (computedChallenge !== authRequest.codeChallenge) {
return Response.json(
{
error: 'invalid_grant',
message: 'Invalid code_verifier - PKCE verification failed',
},
{ status: 400 },
);
}
// Validate DPoP key matches the one from PAR
if (authRequest.dpopJkt && authRequest.dpopJkt !== dpop.jkt) {
return Response.json(
{ error: 'invalid_dpop_proof', error_description: 'DPoP key mismatch' },
{ status: 400 },
);
}
// Delete the auth request (one-time use)
await sharedStorage.deleteOAuthRequest(`code:${code}`);
await sharedStorage.deleteOAuthRequest(codeMapping.requestUri);
if (!authRequest.did) {
return Response.json(
{
error: 'invalid_grant',
error_description: 'Authorization incomplete',
},
{ status: 400 },
);
}
// Create tokens
const tokenId = crypto.randomUUID();
const refreshToken = base64UrlEncode(
crypto.getRandomValues(new Uint8Array(32)),
);
const expiresIn = 3600;
// Create access token JWT
const accessToken = await createOAuthAccessToken({
issuer,
subject: authRequest.did,
clientId: client_id,
scope: authRequest.scope || 'atproto',
tokenId,
dpopJkt: dpop.jkt,
expiresIn,
});
// Store refresh token. Prefer the device captured at consent; the token
// request's own user-agent is a confidential client's server, not the user.
const now = Date.now();
await sharedStorage.putOAuthToken(refreshToken, {
did: authRequest.did,
clientId: client_id,
scope: authRequest.scope || 'atproto',
dpopJkt: dpop.jkt,
createdAt: now,
updatedAt: now,
userAgent: authRequest.userAgent || userAgent,
});
return Response.json(
{
access_token: accessToken,
token_type: 'DPoP',
expires_in: expiresIn,
refresh_token: refreshToken,
scope: authRequest.scope || 'atproto',
sub: authRequest.did,
},
{ headers: { 'DPoP-Nonce': await mintDpopNonce(jwtSecret) } },
);
}
/**
* Handle refresh_token grant
* @param {RefreshGrantParams} params
* @param {DpopProofResult} dpop
* @param {string} issuer
* @returns {Promise<Response>}
*/
async function handleRefreshGrant(params, dpop, issuer) {
const { refresh_token, client_id } = params;
if (!refresh_token || !client_id) {
return Response.json(
{
error: 'invalid_request',
error_description: 'Missing required parameters',
},
{ status: 400 },
);
}
const token = /** @type {OAuthToken|null} */ (
await sharedStorage.getOAuthToken(refresh_token)
);
if (!token) {
return Response.json(
{ error: 'invalid_grant', error_description: 'Invalid refresh token' },
{ status: 400 },
);
}
if (token.clientId !== client_id) {
return Response.json(
{ error: 'invalid_grant', error_description: 'Client mismatch' },
{ status: 400 },
);
}
if (token.dpopJkt !== dpop.jkt) {
return Response.json(
{ error: 'invalid_dpop_proof', error_description: 'DPoP key mismatch' },
{ status: 400 },
);
}
// Authenticate the client — a confidential client authenticates refreshes
// too. Its metadata isn't stored with the token, so re-fetch it.
let refreshClientMetadata;
try {
refreshClientMetadata = await fetchClientMetadata(client_id);
} catch (err) {
return Response.json(
{ error: 'invalid_client', error_description: err.message },
{ status: 400 },
);
}
const clientAuthError = await authenticateClient(
refreshClientMetadata,
params,
issuer,
);
if (clientAuthError) return clientAuthError;
// authenticateClient proved the key of a private_key_jwt client above.
const now = Date.now();
const expired =
refreshClientMetadata.token_endpoint_auth_method === 'private_key_jwt'
? now - token.createdAt > OAUTH_CONFIDENTIAL_SESSION_TTL_MS ||
now - (token.updatedAt ?? token.createdAt) >
OAUTH_CONFIDENTIAL_IDLE_TTL_MS
: now - token.createdAt > OAUTH_PUBLIC_SESSION_TTL_MS;
if (expired) {
await sharedStorage.deleteOAuthToken(refresh_token);
return Response.json(
{ error: 'invalid_grant', error_description: 'Refresh token expired' },
{ status: 400 },
);
}
// Already used: answer with what using it produced, until the client
// shows it has that by using it. A client that never got the reply comes
// back with this one, however much later: a browser app is many pages of
// one session, and a reload, a link followed or a laptop going to sleep
// while a refresh is in flight loses the new tokens before they're
// stored. Refusing it would cost the member their session. The tokens
// are DPoP-bound, so answering a replay gives nothing to anyone without
// the session's key. The replacement is looked up rather than
// remembered, so a session revoked in the meantime stays revoked.
if (token.rotatedTo) {
const current = /** @type {OAuthToken|null} */ (
await sharedStorage.getOAuthToken(token.rotatedTo)
);
if (!current || current.rotatedTo) {
await sharedStorage.deleteOAuthToken(refresh_token);
return Response.json(
{
error: 'invalid_grant',
error_description: 'Invalid refresh token',
},
{ status: 400 },
);
}
const expiresIn = 3600;
return Response.json(
{
access_token: await createOAuthAccessToken({
issuer,
subject: current.did,
clientId: client_id,
scope: current.scope,
tokenId: crypto.randomUUID(),
dpopJkt: dpop.jkt,
expiresIn,
}),
token_type: 'DPoP',
expires_in: expiresIn,
refresh_token: token.rotatedTo,
scope: current.scope,
sub: current.did,
},
{ headers: { 'DPoP-Nonce': await mintDpopNonce(jwtSecret) } },
);
}
// Create new tokens
const newTokenId = crypto.randomUUID();
const newRefreshToken = base64UrlEncode(
crypto.getRandomValues(new Uint8Array(32)),
);
const expiresIn = 3600;
// Create access token JWT
const accessToken = await createOAuthAccessToken({
issuer,
subject: token.did,
clientId: client_id,
scope: token.scope,
tokenId: newTokenId,
dpopJkt: dpop.jkt,
expiresIn,
});
// Update token in storage. createdAt carries over so it keeps meaning
// "first authorized"; updatedAt advances to mark this refresh as the
// session's most recent activity.
await sharedStorage.putOAuthToken(newRefreshToken, {
did: token.did,
clientId: client_id,
scope: token.scope,
dpopJkt: dpop.jkt,
createdAt: token.createdAt,
updatedAt: Date.now(),
userAgent: token.userAgent ?? null,
replaced: refresh_token,
});
// The one just used stays, pointing at the one that replaces it, so a
// page that never got this reply is answered rather than signed out. The
// one it replaced goes: using this one shows the client has it, leaving
// a session one spent token at a time.
await sharedStorage.putOAuthToken(refresh_token, {
...token,
rotatedTo: newRefreshToken,
});
if (token.replaced) await sharedStorage.deleteOAuthToken(token.replaced);
return Response.json(
{
access_token: accessToken,
token_type: 'DPoP',
expires_in: expiresIn,
refresh_token: newRefreshToken,
scope: token.scope,
sub: token.did,
},
{ headers: { 'DPoP-Nonce': await mintDpopNonce(jwtSecret) } },
);
}
/**
* Create OAuth access token JWT
* @param {Object} options
* @param {string} options.issuer
* @param {string} options.subject
* @param {string} options.clientId
* @param {string} options.scope
* @param {string} options.tokenId
* @param {string} options.dpopJkt
* @param {number} options.expiresIn
* @returns {Promise<string>}
*/
async function createOAuthAccessToken({
issuer,
subject,
clientId,
scope,
tokenId,
dpopJkt,
expiresIn,
}) {
const signingKey = await getSigningKey();
if (!signingKey) {
throw new Error('No signing key available');
}
const now = Math.floor(Date.now() / 1000);
const alg =
signingKeyCurve(signingKey) === 'secp256k1' ? 'ES256K' : 'ES256';
const header = { typ: 'at+jwt', alg, kid: 'pds-oauth-key' };
const payload = {
iss: issuer,
sub: subject,
aud: issuer,
client_id: clientId,
scope,
jti: tokenId,
iat: now,
exp: now + expiresIn,
cnf: { jkt: dpopJkt },
};
const headerB64 = base64UrlEncode(
new TextEncoder().encode(JSON.stringify(header)),
);
const payloadB64 = base64UrlEncode(
new TextEncoder().encode(JSON.stringify(payload)),
);
const sigInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const sig = await sign(signingKey, sigInput);
return `${headerB64}.${payloadB64}.${base64UrlEncode(sig)}`;
}
/**
* /oauth/revoke - Token revocation
* @param {Request} request
* @returns {Promise<Response>}
*/
async function handleOAuthRevoke(request) {
if (readOnly) return readOnlyError();
const contentType = request.headers.get('content-type') || '';
let params;
if (contentType.includes('application/json')) {
params = await request.json();
} else {
const text = await request.text();
params = Object.fromEntries(new URLSearchParams(text));
}
const { token, client_id } = params;
if (!token || !client_id) {
return Response.json({ error: 'invalid_request' }, { status: 400 });
}
// Try to delete as refresh token
const storedToken = /** @type {OAuthToken|null} */ (
await sharedStorage.getOAuthToken(token)
);
if (storedToken && storedToken.clientId === client_id) {
await sharedStorage.deleteOAuthToken(token);
}
// Always return 200 per RFC 7009
return new Response(null, { status: 200 });
}
return {
authenticateClient,
createOAuthAccessToken,
issueAuthorizationCode,
handleOAuthJwks,
handleOAuthPar,
/** @type {import('../pds.js').Routes} */
routes: {
'/.well-known/oauth-authorization-server': {
handler: handleOAuthAuthServerMetadata,
},
'/.well-known/oauth-protected-resource': {
handler: handleOAuthProtectedResource,
},
'/oauth/jwks': {
handler: handleOAuthJwks,
},
'/oauth/par': {
method: 'POST',
handler: handleOAuthPar,
},
'/oauth/authorize': {
handler: handleOAuthAuthorize,
},
'/oauth/authorize/passkey': {
method: 'POST',
handler: handleOAuthAuthorizePasskey,
},
'/oauth/token': {
method: 'POST',
handler: handleOAuthToken,
},
'/oauth/revoke': {
method: 'POST',
handler: handleOAuthRevoke,
},
},
};
}