Something went wrong. Try again.
An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350// @pdsjs/core/session - Login lifetimes, the session registry and the account// page cookie. Shared by the XRPC session endpoints, the OAuth grant handlers// and the account pages, which all read the same lifetimes.
import { readJwtExp, readJwtId } from './auth.js';
// Cookie carrying the account page session, and how long it livesexport const ACCOUNT_COOKIE = 'pdsjs_account';export const ACCOUNT_SESSION_TTL = 7 * 24 * 60 * 60;
// How much of a session cookie's life is spent before a request renews it.// Every account page request carries the cookie forward, so a session ends// ACCOUNT_SESSION_TTL after the last visit rather than after the sign-in. A// smaller value reissues the cookie on nearly every request for an hour of// extra life. The window stays at a week because this cookie cannot be revoked// one session at a time.const ACCOUNT_SESSION_RENEW_AFTER = 24 * 60 * 60;
// A password/app-password refresh token lasts 90 days, matching the reference// PDS; the access token it mints stays short-lived. This is what a client holds// to stay signed in.export const REFRESH_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;
// Every session now records its token's own expiry. A row that lacks one// predates the field, so it was minted under the earlier 24h policy; age it by// that, which lets the read path retire those stale rows as it lists them.const LEGACY_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
// How long a refresh token stays redeemable after the rotation that spent it.// A client with several requests in flight refreshes more than once, and a// refresh whose response is lost leaves the client holding a token the server// has already retired; without this window either case ends the session for// good. Redeeming a spent token yields the same replacement its rotation// already named, so the two paths converge rather than fork. Matches the// reference PDS (packages/pds/src/account-manager/account-manager.ts).const REFRESH_GRACE_MS = 2 * 60 * 60 * 1000;
/** * How a session opened with an app password is labelled. Revoking the * credential finds its logins by this, so the two must agree. * @param {string} name - The app password's name * @returns {string} */export function appPasswordLabel(name) { return `App password: ${name}`;}
/** * When a login's refresh token expires, in epoch ms. Prefer the expiry stored * with the session; for a row that predates it, age the last activity by the * earlier lifetime. Null when neither can be read, which counts as no expiry * rather than an expired one. * @param {{expiresAt?: number|string, refreshedAt?: string|null, createdAt?: string}} session * @returns {number|null} */export function sessionExpiryMs(session) { if (session.expiresAt != null) { const exp = typeof session.expiresAt === 'number' ? session.expiresAt * 1000 : Date.parse(session.expiresAt); if (Number.isFinite(exp)) return exp; } const last = Date.parse(session.refreshedAt || session.createdAt || ''); return Number.isFinite(last) ? last + LEGACY_SESSION_TTL_MS : null;}
/** * Whether a login's refresh token is still valid. * @param {{expiresAt?: number|string, refreshedAt?: string|null, createdAt?: string}} session * @param {number} now - epoch ms * @returns {boolean} */export function isSessionLive(session, now) { const expiry = sessionExpiryMs(session); return expiry === null || expiry > now;}
/** * The expiry to shorten a just-spent refresh token to, in epoch seconds, or * null when it is already past use. The grace window only ever brings an * expiry forward: a token near the end of its 90 days does not get more life * out of being rotated. * @param {import('./ports.js').SessionRecord} session * @param {number} now - epoch ms * @returns {number|null} */export function rotationGraceExpiry(session, now) { const own = sessionExpiryMs(session); const grace = now + REFRESH_GRACE_MS; const expiry = own === null ? grace : Math.min(own, grace); return expiry > now ? Math.floor(expiry / 1000) : null;}
/** * The registry of logins opened by password, app password or passkey, over * whatever session storage this deployment has. The XRPC session endpoints * write it and the account pages read it. * @param {import('./ports.js').SharedStoragePort} sharedStorage */export function createSessionRegistry(sharedStorage) { /** * Whether this deployment's storage keeps a session registry. Without one, * password sessions stay stateless: listable nowhere, revocable nowhere. * @returns {boolean} */ function sessionsAvailable() { return ( typeof sharedStorage.putSession === 'function' && typeof sharedStorage.getSession === 'function' ); }
/** * Register a login under its refresh token's id. * @param {string} refreshJwt * @param {{did: string, label: string, scope: string, request: Request, createdAt?: string, dedupe?: boolean}} params * dedupe retires the client's prior session and belongs to a fresh login; * a rotation passes it false, since the session it would collapse is a * different login that happens to share a device. * @returns {Promise<void>} */ async function recordSession( refreshJwt, { did, label, scope, request, createdAt, dedupe = false }, ) { if (!sessionsAvailable()) return; const jti = readJwtId(refreshJwt); if (!jti) return;
// Enough to tell a phone from a laptop, capped so a hostile client cannot // write an essay into the account page. const userAgent = (request.headers.get('user-agent') || '').slice(0, 120) || null;
// A fresh login from the same client retires that client's prior session, // keyed by what opened it and the device it ran on. App-password clients // that sign in each run rather than refreshing would otherwise leave a new // record every time; this keeps one live session per credential per device, // while two genuinely different devices (distinct User-Agents) stay apart. // Spent rows are left alone: one of them is this rotation's own // predecessor, and retiring it would close the grace window immediately. if ( dedupe && typeof sharedStorage.listSessions === 'function' && typeof sharedStorage.deleteSession === 'function' ) { const prior = await sharedStorage.listSessions(did); await Promise.all( prior .filter( (s) => s.jti !== jti && !s.nextJti && s.label === label && (s.userAgent ?? null) === userAgent, ) .map((s) => sharedStorage.deleteSession(s.jti)), ); }
const now = new Date().toISOString(); const exp = readJwtExp(refreshJwt); await sharedStorage.putSession({ jti, did, label, scope, userAgent, createdAt: createdAt || now, refreshedAt: createdAt ? now : null, // The refresh token's own expiry, so the login stops being listed the // moment it can no longer refresh. expiresAt: exp ?? undefined, }); }
/** * The account's live logins, newest first, pruning any whose refresh token * has expired. App-password clients that re-login rather than refresh leave a * record each time; without this they would pile up and overstate access * long after their tokens died. Storage that expires sessions on its own * (a TTL) rarely returns a dead one here, so the prune mostly clears rows * written before sessions carried an expiry. * * A row a rotation has spent is held for its grace window but is not a login * of its own — the successor it names is the same session — so it is pruned * on expiry like any other and never listed. * @param {string} did * @returns {Promise<import('./ports.js').SessionRecord[]>} */ async function listLiveSessions(did) { if ( !sessionsAvailable() || typeof sharedStorage.listSessions !== 'function' ) return []; const now = Date.now(); const all = await sharedStorage.listSessions(did); const live = all.filter((session) => isSessionLive(session, now)); if ( live.length < all.length && typeof sharedStorage.deleteSession === 'function' ) { const expired = all.filter((session) => !isSessionLive(session, now)); await Promise.all( expired.map((session) => sharedStorage.deleteSession(session.jti)), ); } return live.filter((session) => !session.nextJti); }
/** * Spend a session's refresh token: shorten its expiry to the grace window * and name the successor the replacement will be minted under. * * Two refreshes of the same token can both read the row before either * writes, and each would otherwise name a successor of its own, forking one * session into two. Stores that can test and write in a single step settle * that here: the loser is told so and adopts the successor already recorded, * which is what the reference PDS does with its concurrent-refresh retry. A * store without the primitive falls back to an unconditional write, where * the fork is possible but still costs nobody their session. * @param {import('./ports.js').SessionRecord} session * @param {number} graceExpiresAt - Shortened expiry, epoch seconds * @param {string} candidate - The successor id this request would name * @returns {Promise<{nextJti: string, won: boolean}|null>} The successor the * session settled on, or null if it was revoked mid-rotation */ async function spendSession(session, graceExpiresAt, candidate) { if (typeof sharedStorage.spendSession !== 'function') { await sharedStorage.putSession({ ...session, expiresAt: graceExpiresAt, nextJti: candidate, }); return { nextJti: candidate, won: true }; }
if ( await sharedStorage.spendSession(session.jti, graceExpiresAt, candidate) ) return { nextJti: candidate, won: true };
// Lost the race, or the row is gone. Re-reading tells the two apart: a // winner leaves the successor it named behind, and a revoke leaves // nothing. The winner's own row write is still in flight, so it is not // waited on — this request only needs the id to mint under. const settled = await sharedStorage.getSession(session.jti); return settled?.nextJti ? { nextJti: settled.nextJti, won: false } : null; }
/** * End one login, together with the spent rows that could reopen it. Each * rotation leaves its predecessor behind for the grace window, and redeeming * one of those rewrites the row it names — so deleting only the current row * would let a replayed token put the session straight back. Following the * chain backwards closes it. * @param {string} did * @param {string} jti - The session's current refresh token id * @returns {Promise<boolean>} Whether that session existed */ async function revokeSessionChain(did, jti) { if (!sessionsAvailable()) return false; const revoked = await sharedStorage.deleteSession(jti); if (typeof sharedStorage.listSessions !== 'function') return revoked;
const rows = await sharedStorage.listSessions(did); const doomed = new Set([jti]); for (let added = true; added; ) { added = false; for (const row of rows) { if (row.nextJti && doomed.has(row.nextJti) && !doomed.has(row.jti)) { doomed.add(row.jti); added = true; } } } doomed.delete(jti); await Promise.all( [...doomed].map((spent) => sharedStorage.deleteSession(spent)), ); return revoked; }
/** * End every login opened with one app password. Revoking the credential * stops it opening new sessions; without this the ones it already opened * would keep refreshing for their full 90 days. * @param {string} did * @param {string} name - The app password's name * @returns {Promise<number>} How many logins were ended */ async function revokeAppPasswordSessions(did, name) { if ( !sessionsAvailable() || typeof sharedStorage.listSessions !== 'function' ) return 0; const label = appPasswordLabel(name); // Spent rows carry the label too, so the filter closes the whole chain. const doomed = (await sharedStorage.listSessions(did)).filter( (session) => session.label === label, ); await Promise.all( doomed.map((session) => sharedStorage.deleteSession(session.jti)), ); return doomed.filter((session) => !session.nextJti).length; }
return { sessionsAvailable, recordSession, listLiveSessions, spendSession, revokeSessionChain, revokeAppPasswordSessions, };}
/** * Whether a session cookie has spent enough of its life to reissue. * @param {number} exp - The token's expiry, epoch seconds * @param {number} now - epoch seconds * @returns {boolean} */export function accountSessionDueForRenewal(exp, now) { return exp - now <= ACCOUNT_SESSION_TTL - ACCOUNT_SESSION_RENEW_AFTER;}
/** * Serialize the account session cookie. An empty value expires it. * @param {string} value - Session token, or '' to clear the cookie * @param {boolean} secure - Whether to set the Secure attribute * @returns {string} Set-Cookie header value */export function accountCookie(value, secure) { const age = value ? ACCOUNT_SESSION_TTL : 0; return [ `${ACCOUNT_COOKIE}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${age}`, secure ? 'Secure' : null, ] .filter(Boolean) .join('; ');}