// @pdsjs/core/handlers/xrpc-server - com.atproto.server.*: the sessions a // password or app password opens, the app passwords themselves, and the two // ways an account arrives on an empty server. // // createAccount is the migration entry point: it adopts a DID minted // elsewhere. onboarding/create mints a brand-new one. Both run before any // account exists, so neither can use route auth. import { generateAppPassword, hashAppPassword, isAppPasswordScope, SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, } from '../app-password.js'; import { createAccessJwt, createAccountJwt, createRefreshJwt, createServiceJwt, decodeJwtClaims, generateRefreshTokenId, verifyRefreshJwt, } from '../auth.js'; import { base32Encode, base64UrlDecode } from '../crypto.js'; import { drainRequestBody, isSameOriginPost } from '../http.js'; import { generateKemKey } from '../kem.js'; import { buildPlcOperation, signPlcOperationObject } from '../plc.js'; import { cborEncodeDagCbor } from '../repo.js'; import { ACCOUNT_SESSION_TTL, accountCookie, appPasswordLabel, REFRESH_TOKEN_TTL_SECONDS, rotationGraceExpiry, } from '../session.js'; import { isValidHandle } from '../validation.js'; import { parseDidKey } from '../verify.js'; /** * @typedef {Object} XrpcServerContext * @property {import('../ports.js').ActorStoragePort} actorStorage * @property {import('../ports.js').SharedStoragePort} sharedStorage * @property {ReturnType} sessions * @property {string} jwtSecret * @property {string} [hostname] * @property {string} plcUrl * @property {number} blobUploadLimit - Largest accepted blob, in bytes, as describeServer reports it * @property {string} [password] - The configured password, gating the migration claim of an empty server * @property {(candidate: unknown) => Promise} verifyAccountPassword * @property {() => Promise} passwordConfigured * @property {boolean} readOnly * @property {boolean} [spacesEnabled] - Whether the spaces extension serves this account's spaces * @property {import('../ports.js').SignatureVerifierPort|null} verifier * @property {import('../ports.js').DidResolverPort|null} didResolver * @property {() => Promise} getDid * @property {() => Promise} getSigningKey * @property {(auth: {scope?: string}, lxm: string, aud: string) => Response|null} checkRpcScope * @property {() => Promise<{stored: Uint8Array, didKey: string, signingKey: import('../crypto.js').SigningKey}>} generateSigningKey * @property {(scope: string|null|undefined) => Promise<{email?: string, emailConfirmed?: boolean}>} emailSessionFields * @property {(type: 'account'|'identity'|'sync', body: Object) => Promise} emitEvent * @property {(did: string) => Promise} notifyRelay * @property {() => Response} readOnlyError * @property {(did: string|null) => void} setCachedIdentity - Refreshes the DID and signing-key caches after an account is adopted */ /** * @param {XrpcServerContext} ctx * @returns {{ routes: import('../pds.js').Routes }} */ export function createXrpcServerHandlers(ctx) { const { actorStorage, sharedStorage, sessions, jwtSecret, hostname: configuredHostname, plcUrl, blobUploadLimit, password: accountPassword, verifyAccountPassword, passwordConfigured, readOnly, spacesEnabled, verifier, didResolver, getDid, getSigningKey, checkRpcScope, generateSigningKey, emailSessionFields, emitEvent, notifyRelay, readOnlyError, setCachedIdentity, } = ctx; /** * The hostname this server answers as: configured, else what the request * says. It names the did:web identity a migrating account addresses. * @param {Request} request * @returns {string} */ function requestHostname(request) { return ( configuredHostname || request.headers.get('x-hostname') || new URL(request.url).hostname ); } /** * com.atproto.server.describeServer * @param {Request} request * @returns {Promise} */ async function handleDescribeServer(request) { const did = await getDid(); const hostname = requestHostname(request); return Response.json({ // Once an account is hosted, its DID is the server's identity. Before // then the server still needs a stable service identity so a migrating // account can name it as the `aud` of the service token authorizing // createAccount — did:web of our own hostname. did: did || `did:web:${hostname}`, availableUserDomains: [`.${hostname}`], inviteCodeRequired: false, phoneVerificationRequired: false, // As the stock PDS reports it, for a client that splits a large // upload across blobs to size its parts by. blobUploadLimit, links: {}, contact: {}, ...(spacesEnabled ? { spacesAlpha: true } : {}), }); } /** * com.atproto.server.createSession * @param {Request} request * @returns {Promise} */ async function handleCreateSession(request) { if (readOnly) return readOnlyError(); const { identifier, password } = await request.json(); const did = await getDid(); if (!did) { return Response.json( { error: 'InvalidRequest', message: 'PDS not initialized' }, { status: 400 }, ); } // Verify identifier matches DID or handle const handle = await actorStorage.getHandle(); if (identifier !== did && identifier !== handle) { return Response.json( { error: 'AuthenticationRequired', message: 'Invalid identifier' }, { status: 401 }, ); } // The account password grants a full session; an app password grants a // restricted one. The account password is checked first. // No password at all means no password can be correct. Before this // was enforced, an unset password made the comparison short-circuit and // every login succeeded with full access. if (!(await passwordConfigured())) { return Response.json( { error: 'AuthenticationRequired', message: 'Password login is not configured on this server. Set the password option (PDS_PASSWORD).', }, { status: 401 }, ); } let scope = SCOPE_ACCESS; let label = 'Account password'; if (!(await verifyAccountPassword(password))) { const appPassword = await verifyAppPassword(did, password); if (!appPassword) { return Response.json( { error: 'AuthenticationRequired', message: 'Invalid password' }, { status: 401 }, ); } scope = appPassword.privileged ? SCOPE_APP_PASS_PRIVILEGED : SCOPE_APP_PASS; label = appPasswordLabel(appPassword.name); } const accessJwt = await createAccessJwt(did, jwtSecret, 7200, scope); const refreshJwt = await createRefreshJwt( did, jwtSecret, REFRESH_TOKEN_TTL_SECONDS, scope, ); await sessions.recordSession(refreshJwt, { did, label, scope, request, dedupe: true, }); return Response.json({ did, handle: handle || did, accessJwt, refreshJwt, ...(await emailSessionFields(scope)), }); } /** * com.atproto.server.createAccount * * Single-tenant migration entry point: adopt an existing DID onto this * empty server so an account can be moved here from another PDS. Only the * migration shape is supported — an existing `did` is required, and the * server must not already host an account. * * A migrating client authorizes the call with a service token minted at its * old PDS (`getServiceAuth` with aud = this server's did:web identity and * lxm = com.atproto.server.createAccount). We validate the token's claims * and, when a verifier and DID resolver are injected, its signature against * the issuer's atproto key (P-256 built in; secp256k1 — the curve the * reference PDS gives every account — needs the injected verifier the node * and cloudflare packages wire up). A token that cannot be verified is * rejected. A core-only deployment with neither injected falls back to * claims alone. As a manual fallback (local setup, no old PDS), the * configured account password also authorizes the call. * * The repo signing key is freshly generated here; it is what * getRecommendedDidCredentials advertises and what the migrating account * adds to its DID before activateAccount. * * @param {Request} request * @returns {Promise} */ async function handleCreateAccount(request) { if (readOnly) return readOnlyError(); let body; try { body = await request.json(); } catch { return Response.json( { error: 'InvalidRequest', message: 'Invalid request body' }, { status: 400 }, ); } // Single-tenant: a server already hosting an account cannot take another. if (await getDid()) { return Response.json( { error: 'InvalidRequest', message: 'This server already hosts an account', }, { status: 400 }, ); } const did = typeof body.did === 'string' ? body.did : null; if (!did) { return Response.json( { error: 'UnsupportedDomain', message: 'Only account migration is supported: an existing did is required', }, { status: 400 }, ); } const handle = typeof body.handle === 'string' ? body.handle.toLowerCase() : null; if (!handle || !isValidHandle(handle)) { return Response.json( { error: 'InvalidHandle', message: 'A valid handle is required' }, { status: 400 }, ); } const authError = await authorizeCreateAccount( request, did, requestHostname(request), body, ); if (authError) return authError; // Mint a fresh repo signing key. The migrating repo was signed under the // old PDS's key; that history imports as-is, and this key takes over for // records written after the DID is repointed here and activated. const { stored } = await generateSigningKey(); await actorStorage.setPrivateKey(stored); // Published by the PLC operation the account submits to move here, which // getRecommendedDidCredentials builds with this key in it. await actorStorage.setKemKey((await generateKemKey()).privateKey); await actorStorage.setDid(did); await actorStorage.setHandle(handle); // Deactivated until the migration completes: the DID still points at the // old PDS, and the repo/blobs have yet to arrive. await actorStorage.setAccountStatus('deactivated'); setCachedIdentity(did); if (typeof body.email === 'string' && body.email) { await actorStorage.setEmail({ address: body.email, verified: false, }); } const scope = SCOPE_ACCESS; const accessJwt = await createAccessJwt(did, jwtSecret, 7200, scope); const refreshJwt = await createRefreshJwt( did, jwtSecret, REFRESH_TOKEN_TTL_SECONDS, scope, ); await sessions.recordSession(refreshJwt, { did, label: 'Account migration', scope, request, dedupe: true, }); return Response.json({ did, handle, accessJwt, refreshJwt }); } /** * Authorize a createAccount call, either by a service token from the old * PDS or by the configured account password. Returns an error Response when * the caller is not authorized, or null when it is. * * @param {Request} request * @param {string} did - DID being adopted (must be the token issuer) * @param {string} hostname - this server's hostname (its did:web identity) * @param {{password?: unknown}} body * @returns {Promise} */ async function authorizeCreateAccount(request, did, hostname, body) { const bearer = (request.headers.get('authorization') || '').match( /^Bearer\s+(.+)$/i, ); if (bearer) { const invalidToken = () => Response.json( { error: 'AuthenticationRequired', message: 'Invalid service token for account migration', }, { status: 401 }, ); const claims = decodeJwtClaims(bearer[1]); const now = Math.floor(Date.now() / 1000); const claimsOk = claims && claims.iss === did && claims.aud === `did:web:${hostname}` && claims.lxm === 'com.atproto.server.createAccount' && typeof claims.exp === 'number' && claims.exp > now; if (!claimsOk) return invalidToken(); // With a verifier and resolver injected, the token's signature is // checked against the issuer's atproto key — not just its claims. Fail // closed: a token we cannot verify (unresolvable DID, missing key, // unsupported curve, bad signature) is rejected. Without that // infrastructure (a core-only deployment) the claims stand alone. if (verifier && didResolver) { const verified = await verifyServiceTokenSignature(bearer[1], did); if (!verified) return invalidToken(); } return null; } // Manual fallback: the account password. if (await verifyAccountPassword(body.password)) return null; return Response.json( { error: 'AuthenticationRequired', message: 'Account migration requires a service token from the current PDS, ' + 'or the configured account password', }, { status: 401 }, ); } /** * Verify a service token's signature against the atproto verification method * published in its issuer's DID document. Any failure — unresolvable DID, * no atproto key, unsupported curve, an `alg` the key cannot produce, or a * bad signature — returns false. * * @param {string} jwt - the full service token * @param {string} iss - the DID that must have signed it * @returns {Promise} */ async function verifyServiceTokenSignature(jwt, iss) { if (!didResolver || !verifier) return false; try { const parts = jwt.split('.'); if (parts.length !== 3) return false; // Reads past any held answer. This authorizes adopting a DID, it runs // once per account, and a held document can name a key the issuer has // rotated away from — one the account no longer controls. const didDoc = await didResolver(iss, { forceRefresh: true }); const methods = /** @type {any} */ (didDoc)?.verificationMethod; if (!Array.isArray(methods)) return false; const atproto = methods.find( (/** @type {{id?: string}} */ m) => typeof m?.id === 'string' && m.id.endsWith('#atproto'), ); const didKey = atproto?.publicKeyMultibase; if (!didKey) return false; // The signature verifies under the resolved key whatever the header // claims. The reference implementation refuses a header naming another // algorithm, so a token this server accepts is one the rest of the // ecosystem accepts too. const { alg } = JSON.parse( new TextDecoder().decode(base64UrlDecode(parts[0])), ); const expected = parseDidKey(didKey).curve === 'secp256k1' ? 'ES256K' : 'ES256'; if (alg !== expected) return false; const signingInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); const sig = base64UrlDecode(parts[2]); return await verifier.verify(didKey, signingInput, sig); } catch { // Resolution error, unsupported curve with no injected verifier, or a // malformed signature all land here — none of them authorize the call. return false; } } /** * POST /account/onboarding/create — first-run signup of a brand-new identity. * * This is scripts/setup.js moved server-side: generate a signing key, build * and sign the genesis PLC operation, register the did:plc with the * directory, and initialize the server. Only for an empty single-tenant * server, and gated on the configured account password (PDS_PASSWORD) — the * operator's secret — since a fresh identity carries no other proof of who * may claim the box. Returns a cookie session so the browser lands in the * account console. * * @param {Request} request * @param {URL} url * @returns {Promise} */ async function handleOnboardingCreate(request, url) { if (readOnly) return readOnlyError(); if (!isSameOriginPost(request, url)) { return Response.json( { error: 'Forbidden', message: 'Cross-origin request refused' }, { status: 403 }, ); } let body; try { body = await request.json(); } catch { return Response.json( { error: 'InvalidRequest', message: 'Invalid request body' }, { status: 400 }, ); } if (await getDid()) { return Response.json( { error: 'InvalidRequest', message: 'This server already hosts an account', }, { status: 400 }, ); } // Operator gate: only whoever set PDS_PASSWORD may claim an empty server. if (!accountPassword) { return Response.json( { error: 'AuthenticationRequired', message: 'Password login is not configured on this server. Set PDS_PASSWORD.', }, { status: 401 }, ); } if (body.password !== accountPassword) { return Response.json( { error: 'AuthenticationRequired', message: 'Invalid server password' }, { status: 401 }, ); } const hostname = requestHostname(request); // A bare handle defaults to the server's own hostname (the self-verifying // case). Anything else is taken as given and baked into the genesis op; // an external domain simply reads as unverified until its DNS is set. A // trailing :port is stripped — it can ride along from a local dev host but // is never part of a handle. const requested = ( typeof body.handle === 'string' && body.handle.trim() ? body.handle.trim() : hostname ) .toLowerCase() .replace(/^@/, '') .replace(/:\d+$/, ''); if (!isValidHandle(requested)) { return Response.json( { error: 'InvalidHandle', message: `"${requested}" is not a valid handle. Use a domain like you.example.com.`, }, { status: 400 }, ); } // Mint the identity and its genesis PLC operation. const { stored, didKey, signingKey } = await generateSigningKey(); const kemKey = await generateKemKey(); const operation = await signPlcOperationObject( buildPlcOperation({ didKey, handle: requested, pdsEndpoint: `https://${hostname}`, rotationKeys: [didKey], kemDidKey: kemKey.didKey, }), signingKey, ); const hash = new Uint8Array( await crypto.subtle.digest( 'SHA-256', /** @type {BufferSource} */ (cborEncodeDagCbor(operation)), ), ); const did = `did:plc:${base32Encode(hash.slice(0, 15))}`; // Publish it to the PLC directory before touching local state, so a // rejected registration leaves the server still empty and retryable. let plcRes; try { plcRes = await fetch(`${plcUrl}/${encodeURIComponent(did)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(operation), }); } catch (err) { return Response.json( { error: 'PlcRegistrationFailed', message: `Could not reach the PLC directory: ${err instanceof Error ? err.message : String(err)}`, }, { status: 502 }, ); } if (!plcRes.ok) { const detail = await plcRes.text().catch(() => ''); return Response.json( { error: 'PlcRegistrationFailed', message: `PLC directory rejected the identity (${plcRes.status}): ${detail}`, }, { status: 502 }, ); } // Adopt the identity locally (mirrors handleInit's bootstrap). await actorStorage.setDid(did); await actorStorage.setPrivateKey(stored); await actorStorage.setKemKey(kemKey.privateKey); await actorStorage.setHandle(requested); if (typeof body.email === 'string' && body.email) { await actorStorage.setEmail({ address: body.email, verified: false, }); } await sharedStorage.putActor(did, requested, Date.now()); setCachedIdentity(null); await emitEvent('identity', { handle: requested }); await emitEvent('account', { active: true }); await notifyRelay(did); const token = await createAccountJwt(did, jwtSecret, ACCOUNT_SESSION_TTL); return new Response(JSON.stringify({ ok: true, did, handle: requested }), { status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Set-Cookie': accountCookie(token, url.protocol === 'https:'), }, }); } /** * com.atproto.server.deleteSession - End the session its refresh token names * @param {Request} request * @returns {Promise} */ async function handleDeleteSession(request) { await drainRequestBody(request); const match = (request.headers.get('authorization') || '').match( /^Bearer\s+(.+)$/i, ); if (!match) { return Response.json( { error: 'AuthenticationRequired', message: 'Refresh token required' }, { status: 401 }, ); } let payload; try { payload = await verifyRefreshJwt(match[1], jwtSecret); } catch { return Response.json( { error: 'InvalidToken', message: 'Invalid refresh token' }, { status: 401 }, ); } // Succeeds either way: the caller asked for this session to be over, and // on a server with no registry it already is as far as anything here can // tell. Reporting failure would only invite a retry loop. if (sessions.sessionsAvailable() && payload.jti) { await sessions.revokeSessionChain(payload.sub, payload.jti); } return Response.json({}); } /** * com.atproto.server.getSession * @param {Request} _request * @param {URL} _url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleGetSession(_request, _url, auth) { if (!auth) { return Response.json( { error: 'AuthenticationRequired', message: 'Authentication required' }, { status: 401 }, ); } const handle = await actorStorage.getHandle(); return Response.json({ did: auth.did, handle: handle || auth.did, ...(await emailSessionFields(auth.scope)), }); } /** * Look up an app password by its hash. * @param {string} did * @param {string} password * @returns {Promise} */ async function verifyAppPassword(did, password) { if (!sharedStorage.getAppPasswordByHash) return null; const hash = await hashAppPassword(did, password); return sharedStorage.getAppPasswordByHash(did, hash); } /** * App passwords may not manage app passwords. Otherwise one leaked password * mints replacements for itself and revoking it achieves nothing. * @param {import('../pds.js').Auth} auth * @returns {Response|null} An error response, or null when allowed */ function requireFullSession(auth) { if (isAppPasswordScope(auth.scope)) { return Response.json( { error: 'InvalidToken', message: 'App passwords cannot manage app passwords', }, { status: 403 }, ); } return null; } /** * com.atproto.server.createAppPassword * @param {Request} request * @param {URL} _url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleCreateAppPassword(request, _url, auth) { if (readOnly) return readOnlyError(); if (!auth) { return Response.json( { error: 'AuthenticationRequired', message: 'Authentication required' }, { status: 401 }, ); } const denied = requireFullSession(auth); if (denied) return denied; const { name, privileged = false } = await request.json(); if (!name || typeof name !== 'string') { return Response.json( { error: 'InvalidRequest', message: 'name is required' }, { status: 400 }, ); } const password = generateAppPassword(); const hash = await hashAppPassword(auth.did, password); const createdAt = new Date().toISOString(); const stored = await sharedStorage.putAppPassword( auth.did, name, hash, Boolean(privileged), createdAt, ); if (!stored) { return Response.json( { error: 'InvalidRequest', message: `App password "${name}" already exists`, }, { status: 400 }, ); } // The only time the password is ever returned. Nothing but its hash is // stored, so a caller that loses this cannot recover it. return Response.json({ name, password, createdAt, privileged: Boolean(privileged), }); } /** * com.atproto.server.listAppPasswords * @param {Request} _request * @param {URL} _url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleListAppPasswords(_request, _url, auth) { if (!auth) { return Response.json( { error: 'AuthenticationRequired', message: 'Authentication required' }, { status: 401 }, ); } const denied = requireFullSession(auth); if (denied) return denied; const passwords = await sharedStorage.listAppPasswords(auth.did); return Response.json({ passwords }); } /** * com.atproto.server.revokeAppPassword * @param {Request} request * @param {URL} _url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleRevokeAppPassword(request, _url, auth) { if (readOnly) return readOnlyError(); if (!auth) { return Response.json( { error: 'AuthenticationRequired', message: 'Authentication required' }, { status: 401 }, ); } const denied = requireFullSession(auth); if (denied) return denied; const { name } = await request.json(); if (!name || typeof name !== 'string') { return Response.json( { error: 'InvalidRequest', message: 'name is required' }, { status: 400 }, ); } const revoked = await sharedStorage.deleteAppPassword(auth.did, name); if (!revoked) { return Response.json( { error: 'InvalidRequest', message: `No app password named "${name}"` }, { status: 400 }, ); } await sessions.revokeAppPasswordSessions(auth.did, name); return new Response(null, { status: 200 }); } /** * com.atproto.server.refreshSession * @param {Request} request * @returns {Promise} */ async function handleRefreshSession(request) { if (readOnly) return readOnlyError(); const authHeader = request.headers.get('authorization'); if (!authHeader) { return Response.json( { error: 'AuthenticationRequired', message: 'Refresh token required' }, { status: 401 }, ); } const match = authHeader.match(/^Bearer\s+(.+)$/i); if (!match) { return Response.json( { error: 'InvalidToken', message: 'Invalid authorization header' }, { status: 401 }, ); } try { const payload = await verifyRefreshJwt(match[1], jwtSecret); const did = payload.sub; // A revoked session's token still verifies — it is a signed JWT — so // the registry is what actually ends it. Servers whose storage has no // registry keep the old stateless behaviour. let session = null; let graceExpiresAt = null; if (sessions.sessionsAvailable()) { session = payload.jti ? await sharedStorage.getSession(payload.jti) : null; if (!session) { return Response.json( { error: 'InvalidToken', message: 'Session has been revoked' }, { status: 401 }, ); } // A spent token stays redeemable for its grace window, then stops. graceExpiresAt = rotationGraceExpiry(session, Date.now()); if (!graceExpiresAt) { return Response.json( { error: 'ExpiredToken', message: 'Refresh token has expired' }, { status: 400 }, ); } } // Settle which id the replacement is minted under before minting it, so // that every path — a first rotation, a replay, and a rotation that lost // a race — issues the one token this session actually moved to. let nextJti; // Whether this request is the one that has to write the successor's row. let recordSuccessor = false; if (session?.nextJti) { // A replay. The successor stands on its own by now — it may even have // rotated again since — so it is reissued, not rewritten: writing it // back would drop the successor of its own rotation and leave two // live tokens where the session has one. Its row going missing means // the session was revoked out from under this token. const successor = await sharedStorage.getSession(session.nextJti); if (!successor) { return Response.json( { error: 'InvalidToken', message: 'Session has been revoked' }, { status: 401 }, ); } nextJti = session.nextJti; } else if (session && graceExpiresAt) { const claimed = await sessions.spendSession( session, graceExpiresAt, generateRefreshTokenId(), ); if (!claimed) { return Response.json( { error: 'InvalidToken', message: 'Session has been revoked' }, { status: 401 }, ); } nextJti = claimed.nextJti; recordSuccessor = claimed.won; } // Reissue at the same access level. Dropping this would let an // app-password session refresh its way into a full-access one. const scope = payload.sessionScope || SCOPE_ACCESS; const accessJwt = await createAccessJwt(did, jwtSecret, 7200, scope); const refreshJwt = await createRefreshJwt( did, jwtSecret, REFRESH_TOKEN_TTL_SECONDS, scope, nextJti, ); if (session && recordSuccessor) { await sessions.recordSession(refreshJwt, { did, label: session.label, scope, request, createdAt: session.createdAt, }); } const handle = await actorStorage.getHandle(); return Response.json({ did, handle: handle || did, accessJwt, refreshJwt, ...(await emailSessionFields(scope)), }); } catch (error) { // Wrong token type (access token used instead of refresh) is a bad request if (error.message.includes('Invalid token type')) { return Response.json( { error: 'InvalidRequest', message: 'Refresh token required, access token provided', }, { status: 400 }, ); } return Response.json( { error: 'InvalidToken', message: error.message }, { status: 401 }, ); } } /** * com.atproto.server.getServiceAuth * @param {Request} _request * @param {URL} url * @param {import('../pds.js').Auth|null} auth * @returns {Promise} */ async function handleGetServiceAuth(_request, url, auth) { if (!auth) { return Response.json( { error: 'AuthenticationRequired', message: 'Authentication required' }, { status: 401 }, ); } const aud = url.searchParams.get('aud'); const lxm = url.searchParams.get('lxm'); if (!aud || !lxm) { return Response.json( { error: 'InvalidRequest', message: 'aud and lxm parameters are required', }, { status: 400 }, ); } // Granular tokens need an rpc: scope covering this method and audience const scopeError = checkRpcScope( auth, lxm, aud.includes('#') ? aud : `${aud}#atproto_pds`, ); if (scopeError) return scopeError; const expParam = url.searchParams.get('exp'); let exp; if (expParam !== null) { exp = Number(expParam); const badExpiration = (/** @type {string} */ message) => Response.json({ error: 'BadExpiration', message }, { status: 400 }); if (!Number.isFinite(exp)) { return badExpiration('exp must be a Unix timestamp in seconds'); } const seconds = exp - Math.floor(Date.now() / 1000); if (seconds < 0) return badExpiration('expiration is in past'); if (seconds > 3600) { return badExpiration( 'cannot request a token with an expiration more than an hour in the future', ); } } const did = await getDid(); const signingKey = await getSigningKey(); if (!did || !signingKey) { return Response.json( { error: 'InvalidRequest', message: 'PDS not initialized' }, { status: 400 }, ); } // Service JWTs always carry the bare DID audience (proposal 0014 phase 1) const token = await createServiceJwt({ iss: did, aud: aud.split('#')[0], lxm, signingKey, exp, }); return Response.json({ token }); } return { routes: { '/xrpc/com.atproto.server.describeServer': { handler: handleDescribeServer, }, '/xrpc/com.atproto.server.createSession': { method: 'POST', handler: handleCreateSession, }, // No `auth`: reachable on an uninitialized server, and it validates the // migration service token itself. '/xrpc/com.atproto.server.createAccount': { method: 'POST', handler: handleCreateAccount, }, '/xrpc/com.atproto.server.getSession': { auth: 'required', handler: handleGetSession, }, '/xrpc/com.atproto.server.getServiceAuth': { auth: 'required', handler: handleGetServiceAuth, }, '/xrpc/com.atproto.server.createAppPassword': { method: 'POST', auth: 'required', handler: handleCreateAppPassword, }, '/xrpc/com.atproto.server.listAppPasswords': { auth: 'required', handler: handleListAppPasswords, }, '/xrpc/com.atproto.server.revokeAppPassword': { method: 'POST', auth: 'required', handler: handleRevokeAppPassword, }, '/xrpc/com.atproto.server.deleteSession': { method: 'POST', handler: handleDeleteSession, }, '/xrpc/com.atproto.server.refreshSession': { method: 'POST', handler: handleRefreshSession, }, // First-run signup. No route auth: it runs before any account exists and // gates itself on the server password. '/account/onboarding/create': { method: 'POST', handler: handleOnboardingCreate, }, }, }; }