chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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<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('; ');
}