Files
webmail/lib/auth/session-age.ts
T

208 lines
8.3 KiB
TypeScript

/**
* 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<ReturnType<typeof cookies>>;
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<number> {
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<number> | 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<number | null> {
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<number> {
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<string, unknown> = 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<string, unknown> = { 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 };
}