// @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 lives export 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} */ 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} */ 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} 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} 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('; '); }