chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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<typeof import('../session.js').createSessionRegistry>} 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<boolean>} verifyAccountPassword
* @property {() => Promise<boolean>} 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<string|null>} getDid
* @property {() => Promise<import('../crypto.js').SigningKey|null>} 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<void>} emitEvent
* @property {(did: string) => Promise<void>} 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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response|null>}
*/
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<boolean>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<import('../ports.js').AppPasswordRecord|null>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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<Response>}
*/
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,
},
},
};
}