~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
349 lines · 13.4 KB Raw History
// @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('; ');
}