/** * The organisation's maximum session length (policy.json * `organisation.session.maxHours`) on the server side. * * JMAP goes from the browser straight to Stalwart, so the client's timer * signs users out on time (components/session/session-timeout-guard.tsx). * The server's part is to stop a session older than the maximum from coming * back: the routes that restore a remembered sign-in (/api/auth/session) or * renew a provider token (/api/auth/token) refuse it and clear its cookies. * * The sign-in time travels in the remembered-session cookie (`signedInAt` in * lib/auth/crypto.ts) and, for token sign-ins, in a stamp cookie of its own * that is bound to the refresh token by a hash and rewritten with it on * every rotation. A cookie or token without a sign-in time (one written * before this existed, or a stamp that was deleted or doesn't match the * token) counts as signed in at one fixed time: when the server first * enforced the limits (limitsEnforcedSince). Deleting the stamp therefore * never makes a session younger. * * The server knows an account's address, not its groups, so only Everyone * and the domain scopes count here; a group scope's value is enforced by the * client alone. */ import { createHash } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import type { cookies } from 'next/headers'; import { configManager } from '@/lib/admin/config-manager'; import { ensureStateDir, getStatePath } from '@/lib/admin/paths'; import { resolvePolicy } from '@/lib/admin/policy-scopes'; import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; import { hasSessionSecret } from '@/lib/auth/session-secret'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { logger } from '@/lib/logger'; export { SESSION_TOO_OLD } from '@/lib/session-policy/constants'; type CookieStore = Awaited>; const HOUR_MS = 60 * 60 * 1000; const positiveHours = (value: unknown): number => (typeof value === 'number' && value > 0 ? value : 0); /** * The maximum session length (hours, 0 = none) for an account's address. * Without an address (a token sign-in whose stamp names no account, which * includes one whose stamp was deleted) the strictest value of Everyone and * every domain entry applies, so losing the account never loosens the limit. */ export async function maxSessionHoursFor(account: string | null | undefined): Promise { try { await configManager.ensureLoaded?.(); const raw = configManager.getPolicy(); const email = account?.trim().toLowerCase() ?? ''; if (email) return positiveHours(resolvePolicy(raw, { email, groups: [] }).policy.organisation?.session?.maxHours); const values = [ positiveHours(resolvePolicy(raw, null).policy.organisation?.session?.maxHours), ...(raw.scopes ?? []) .filter((scope) => (scope.match?.domains?.length ?? 0) > 0) .map((scope) => positiveHours(scope.policy?.organisation?.session?.maxHours)), ].filter((hours) => hours > 0); return values.length > 0 ? Math.min(...values) : 0; } catch (error) { logger.warn('Could not read the maximum session length', { error: error instanceof Error ? error.message : 'Unknown error' }); return 0; } } /** Whether a session signed in at `signedInAt` has outlived `maxHours`. */ export function isPastMaxAge(signedInAt: number, maxHours: number, now = Date.now()): boolean { if (!maxHours) return false; return now - signedInAt >= maxHours * HOUR_MS; } // ── The fixed time for sessions without a sign-in time ───────────────────── const ENFORCED_SINCE_FILE = 'session-limits-since'; const PROCESS_START = Date.now() - Math.round(process.uptime() * 1000); let enforcedSince: Promise | null = null; function parseTime(text: string): number | null { const value = Number(text.trim()); return Number.isFinite(value) && value > 0 ? value : null; } async function readRecorded(path: string): Promise { try { return parseTime(await readFile(path, 'utf8')); } catch { return null; } } /** * When this server first enforced the session limits, recorded once in the * admin state dir. Falls back to the process start when the file can't be * written (a read-only state dir), which restarts the count on a restart. */ export function limitsEnforcedSince(): Promise { enforcedSince ??= (async () => { const path = getStatePath(ENFORCED_SINCE_FILE); const recorded = await readRecorded(path); if (recorded) return recorded; const now = Date.now(); try { await ensureStateDir(); // 'wx': a second process starting at the same moment keeps the first one's time. await writeFile(path, String(now), { flag: 'wx' }); return now; } catch { const other = await readRecorded(path); if (other) return other; logger.warn('Could not record when the session limits were first enforced; counting from the process start'); return PROCESS_START; } })(); return enforcedSince; } /** Test helper. */ export function resetLimitsEnforcedSince(): void { enforcedSince = null; } // ── The sign-in stamp of a token sign-in ─────────────────────────────────── /** * The sign-in time of a token sign-in, bound to the refresh token it was * written with. `account` is filled in once the auth context names it. */ export interface SignInStamp { signedInAt: number; tokenHash: string; account?: string; } export function refreshTokenHash(refreshToken: string): string { return createHash('sha256').update(refreshToken).digest('base64url'); } export function signInStampCookieName(slot: number): string { return slot === 0 ? 'jmap_signed_in' : `jmap_signed_in_${slot}`; } /** The slot's stamp, or null when it is missing, unreadable, or written for another refresh token. */ export function readSignInStamp(cookieStore: CookieStore, slot: number, refreshToken: string): SignInStamp | null { const value = cookieStore.get(signInStampCookieName(slot))?.value; if (!value) return null; try { const payload = decryptPayload(value, 'session-age'); const signedInAt = payload?.signedInAt; if (typeof signedInAt !== 'number' || !Number.isFinite(signedInAt)) return null; if (payload?.tokenHash !== refreshTokenHash(refreshToken)) return null; const account = typeof payload.account === 'string' ? payload.account : undefined; return { signedInAt, tokenHash: payload.tokenHash, ...(account ? { account } : {}) }; } catch { return null; } } let missingSecretLogged = false; /** * Written whenever the refresh token cookie is, with that cookie's options. * Without a session secret nothing can be sealed; the client's timer still applies. */ export function writeSignInStamp( cookieStore: CookieStore, slot: number, stamp: { signedInAt: number; refreshToken: string; account?: string }, options: Record = getCookieOptions(), ): void { if (!hasSessionSecret()) { if (!missingSecretLogged) { missingSecretLogged = true; logger.warn('No session secret: the maximum session length is enforced by the browser only'); } return; } try { const payload: Record = { signedInAt: stamp.signedInAt, tokenHash: refreshTokenHash(stamp.refreshToken) }; if (stamp.account) payload.account = stamp.account; cookieStore.set(signInStampCookieName(slot), encryptPayload(payload, 'session-age'), options); } catch (error) { logger.warn('Could not record the sign-in time', { error: error instanceof Error ? error.message : 'Unknown error' }); } } export function clearSignInStamp(cookieStore: CookieStore, slot: number): void { const name = signInStampCookieName(slot); if (cookieStore.get(name)) cookieStore.delete(name); } /** * When a token sign-in happened: its stamp's time, or the fixed time when * there is no stamp for this refresh token. Null without a session secret, * when the server can't keep sign-in times at all. */ export async function tokenSignedInAt( cookieStore: CookieStore, slot: number, refreshToken: string, ): Promise<{ signedInAt: number; stamp: SignInStamp | null } | null> { if (!hasSessionSecret()) return null; const stamp = readSignInStamp(cookieStore, slot, refreshToken); return { signedInAt: stamp?.signedInAt ?? await limitsEnforcedSince(), stamp }; }