Repository Public
1/**2 * Auth sessions. The cookie carries a 32-byte random token; only its sha256 is stored.3 * Idle timeout 12 h (sliding, `last_seen_at` written at most once a minute), absolute 30 d.4 * Sessions without a completed second factor live 15 min at most.5 */6import type { Queryable } from '../db.js';7import { randomToken, sha256Hex } from './crypto.js';89export const IDLE_SEC = 12 * 3600;10export const ABSOLUTE_SEC = 30 * 24 * 3600;11export const RESTRICTED_SEC = 15 * 60;1213export interface SessionRow {14 id: string;15 user_id: string;16 username: string;17 csrf_token: string;18 second_factor: boolean;19 pending: Record<string, unknown>;20 created_at: Date;21 last_seen_at: Date;22 expires_at: Date;23}2425export async function createSession(26 db: Queryable,27 o: { userId: string; secondFactor: boolean; userAgent: string | null; ip: string | null },28): Promise<{ token: string; id: string; csrfToken: string }> {29 const token = randomToken(32);30 const csrfToken = randomToken(24);31 const row = await db.one<{ id: string }>(32 `INSERT INTO auth_sessions (user_id, token_hash, csrf_token, second_factor, user_agent, ip, expires_at)33 VALUES ($1, $2, $3, $4, $5, $6, now() + make_interval(secs => $7)) RETURNING id`,34 [o.userId, sha256Hex(token), csrfToken, o.secondFactor, o.userAgent?.slice(0, 300) ?? null, o.ip, o.secondFactor ? ABSOLUTE_SEC : RESTRICTED_SEC],35 );36 if (!row) throw new Error('session insert failed');37 return { token, id: row.id, csrfToken };38}3940const SELECT = `SELECT s.id, s.user_id, u.username, s.csrf_token, s.second_factor, s.pending,41 s.created_at, s.last_seen_at, s.expires_at42 FROM auth_sessions s JOIN users u ON u.id = s.user_id`;43const VALID = `s.revoked_at IS NULL AND s.expires_at > now() AND s.last_seen_at > now() - make_interval(secs => ${IDLE_SEC})`;4445export async function findSessionByToken(db: Queryable, token: string): Promise<SessionRow | null> {46 if (token.length < 20 || token.length > 200) return null;47 const row = await db.one<SessionRow>(`${SELECT} WHERE s.token_hash = $1 AND ${VALID}`, [sha256Hex(token)]);48 if (row && Date.now() - row.last_seen_at.getTime() > 60_000) {49 await db.q('UPDATE auth_sessions SET last_seen_at = now() WHERE id = $1', [row.id]);50 }51 return row;52}53/** One lookup per request: the rate limiter (status route) and the auth guard share it. */54const lookups = new WeakMap<object, Promise<SessionRow | null>>();55export function findSessionForRequest(db: Queryable, req: object, token: string): Promise<SessionRow | null> {56 let p = lookups.get(req);57 if (!p) {58 p = findSessionByToken(db, token);59 lookups.set(req, p);60 }61 return p;62}63export const findSessionById = (db: Queryable, id: string): Promise<SessionRow | null> =>64 db.one<SessionRow>(`${SELECT} WHERE s.id = $1 AND ${VALID}`, [id]);6566/** New token + CSRF token for the same row, marking the second factor complete (fixation defence). */67export async function upgradeSession(db: Queryable, id: string): Promise<{ token: string; csrfToken: string }> {68 const token = randomToken(32);69 const csrfToken = randomToken(24);70 await db.q(71 `UPDATE auth_sessions SET token_hash = $2, csrf_token = $3, second_factor = true, pending = '{}',72 last_seen_at = now(), expires_at = now() + make_interval(secs => $4) WHERE id = $1`,73 [id, sha256Hex(token), csrfToken, ABSOLUTE_SEC],74 );75 return { token, csrfToken };76}7778export async function setPending(db: Queryable, id: string, key: string, value: unknown): Promise<void> {79 if (value === null) await db.q('UPDATE auth_sessions SET pending = pending - $2 WHERE id = $1', [id, key]);80 else await db.q('UPDATE auth_sessions SET pending = pending || jsonb_build_object($2::text, $3::jsonb) WHERE id = $1', [id, key, JSON.stringify(value)]);81}8283export async function revokeSession(db: Queryable, id: string): Promise<void> {84 await db.q('UPDATE auth_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL', [id]);85}86/** Revokes every other session of the user; returns their ids (to close their WebSockets). */87export async function revokeOtherSessions(db: Queryable, userId: string, keepId: string | null): Promise<string[]> {88 const rows = await db.q<{ id: string }>(89 `UPDATE auth_sessions SET revoked_at = now()90 WHERE user_id = $1 AND revoked_at IS NULL AND ($2::uuid IS NULL OR id <> $2::uuid) RETURNING id`,91 [userId, keepId],92 );93 return rows.map((r) => r.id);94}9596/** Effective expiry shown to the browser: the earlier of the absolute and the idle deadline. */97export function effectiveExpiry(row: Pick<SessionRow, 'expires_at' | 'last_seen_at'>): Date {98 return new Date(Math.min(row.expires_at.getTime(), row.last_seen_at.getTime() + IDLE_SEC * 1000));99}100