Files
ThothII/backend/src/auth/session-store.ts
T

755 lines
32 KiB
TypeScript

import { createHash, hkdfSync, randomBytes } from "node:crypto";
import { z } from "zod";
import type { PrincipalContext } from "./principal.js";
import type {
AuthSessionRecord,
OidcStateRecord,
OidcTransactionTransport,
Permission,
Role,
} from "./types.js";
import {
createPosixAuthStorageBridge,
createWindowsAuthStorageBridge,
type WindowsAuthStorageBridge,
} from "./windows-auth-storage.js";
const TOKEN_BYTES = 32;
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const DIGEST_FILENAME_PATTERN = /^[a-f0-9]{64}\.json$/;
const CLAIM_FILENAME_PATTERN = /^[a-f0-9]{64}\.claim$/;
const OIDC_SLOT_FILENAME_PATTERN = /^slot-(\d{2})\.json$/;
const MAX_SESSION_RECORD_BYTES = 16 * 1024;
const MAX_OIDC_STATE_RECORD_BYTES = 8 * 1024;
const MAX_OIDC_SLOT_RECORD_BYTES = 512;
const MAX_TTL_MS = 365 * 24 * 60 * 60 * 1000;
const OIDC_STATE_TTL_MS = 10 * 60 * 1000;
const OIDC_STATE_CAPACITY = 64;
const MAX_OIDC_STORAGE_ENTRIES = OIDC_STATE_CAPACITY * 3;
const MAX_SESSION_PRUNE_ENTRIES = 512;
const TOUCH_INTERVAL_MS = 5 * 60 * 1000;
const CSRF_CONTEXT = Buffer.from("thothii-csrf-v1", "utf8");
const EMPTY_HKDF_SALT = Buffer.alloc(0);
const ROLES = ["user", "admin"] as const;
const PERMISSIONS = [
"session.use", "session.read_all", "session.manage_all", "settings.manage",
"workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage", "auth.diagnostics.read",
] as const satisfies readonly Permission[];
const invalid = (): Error => new Error("auth_session_store_invalid");
export interface SessionCreateInput {
principal: PrincipalContext;
method: "local" | "oidc" | "upstream";
remembered: boolean;
userAuthRevision?: number;
authConfigRevision: string;
idleTtlMs: number;
absoluteTtlMs: number;
}
export interface CreatedAuthSession {
token: string;
csrfToken: string;
record: AuthSessionRecord;
}
export interface OidcStateCreateInput {
nonce: string;
codeVerifier: string;
returnTo: "/";
authConfigRevision: string;
issuer: string;
browserTransactionDigest: string;
browserTransactionTransport: OidcTransactionTransport;
}
export interface CreatedOidcState {
state: string;
record: OidcStateRecord;
}
export interface LocalSessionUser {
enabled: boolean;
authRevision: number;
roles: readonly Role[];
}
export interface CurrentLocalSessionUser {
revision: string;
user: LocalSessionUser | undefined;
}
/** Operational validity-source failures must not masquerade as revoked credentials. */
export class AuthSessionOperationalError extends Error {
constructor() {
super("auth_session_operational_error");
}
}
export class OidcStateCapacityError extends Error {
constructor() {
super("auth_oidc_state_capacity");
}
}
/**
* The route layer supplies the current installation revision and local-registry lookup.
* Supplying this hook makes every resolve an authorization-generation check.
*/
export interface AuthSessionValidity {
currentAuthConfigRevision(): string | Promise<string>;
findLocalUser?(subject: string): LocalSessionUser | undefined | Promise<LocalSessionUser | undefined>;
currentLocalUser?(subject: string): CurrentLocalSessionUser | Promise<CurrentLocalSessionUser>;
}
export interface AuthSessionStore {
create(input: SessionCreateInput, now?: Date): Promise<CreatedAuthSession>;
resolve(token: string, now?: Date, validity?: AuthSessionValidity): Promise<AuthSessionRecord | undefined>;
touch(token: string, now?: Date): Promise<void>;
revoke(token: string): Promise<void>;
prune(now?: Date): Promise<number>;
createOidcState(input: OidcStateCreateInput, now?: Date): Promise<CreatedOidcState>;
consumeOidcState(state: string, now?: Date): Promise<OidcStateRecord | undefined>;
}
/** Narrow test seams for the native tht-backed storage adaptors. */
export interface FileAuthSessionStoreOptions {
windowsStorageBridge?: WindowsAuthStorageBridge;
/** Test seam; production uses the bounded hidden tht bridge for every POSIX record operation. */
posixStorageBridge?: WindowsAuthStorageBridge;
/** Test-only capacity seam; production always uses the fixed 64-state bound. */
oidcStateCapacity?: number;
}
interface SessionDirectoryPage {
entries: string[];
more: boolean;
}
const text = z.string().min(1).max(512).refine((value) => !/[\u0000-\u001f\u007f]/.test(value));
const timestamp = z.string().length(24).refine((value) => {
const parsed = Date.parse(value);
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
});
const role = z.enum(ROLES);
const permission = z.enum(PERMISSIONS);
const distinct = <T>(items: readonly T[]): boolean => new Set(items).size === items.length;
const sessionRecordSchema = z.strictObject({
version: z.literal(1),
issuer: text,
subject: text,
displayName: text.optional(),
method: z.enum(["local", "oidc", "upstream"]),
roles: z.array(role).max(ROLES.length).refine(distinct),
permissions: z.array(permission).max(PERMISSIONS.length).refine(distinct),
userAuthRevision: z.number().int().positive().safe().optional(),
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
remembered: z.boolean(),
createdAt: timestamp,
lastSeenAt: timestamp,
idleExpiresAt: timestamp,
absoluteExpiresAt: timestamp,
}).superRefine((record, context) => {
const createdAt = Date.parse(record.createdAt);
const lastSeenAt = Date.parse(record.lastSeenAt);
const idleExpiresAt = Date.parse(record.idleExpiresAt);
const absoluteExpiresAt = Date.parse(record.absoluteExpiresAt);
if (lastSeenAt < createdAt || idleExpiresAt < lastSeenAt || idleExpiresAt > absoluteExpiresAt
|| absoluteExpiresAt < createdAt || absoluteExpiresAt - createdAt > MAX_TTL_MS) {
context.addIssue({ code: "custom", message: "invalid session lifetime" });
}
if (record.method === "local" && record.userAuthRevision === undefined) {
context.addIssue({ code: "custom", message: "local revision is required" });
}
if (record.method !== "local" && record.userAuthRevision !== undefined) {
context.addIssue({ code: "custom", message: "non-local revision is forbidden" });
}
});
const oidcStateRecordSchema = z.strictObject({
version: z.literal(1),
nonce: z.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/),
codeVerifier: z.string().min(43).max(128).regex(/^[A-Za-z0-9._~-]+$/),
returnTo: z.literal("/"),
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
// Existing ten-minute records from before this field was introduced can be consumed and
// rejected by the route. New records always receive the required input field below.
browserTransactionTransport: z.enum(["https", "loopback_http"]).optional(),
capacitySlot: z.number().int().min(0).max(OIDC_STATE_CAPACITY - 1).optional(),
createdAt: timestamp,
expiresAt: timestamp,
}).superRefine((record, context) => {
const lifetime = Date.parse(record.expiresAt) - Date.parse(record.createdAt);
if (lifetime <= 0 || lifetime > OIDC_STATE_TTL_MS) {
context.addIssue({ code: "custom", message: "invalid OIDC state lifetime" });
}
});
const oidcSlotRecordSchema = z.strictObject({
version: z.literal(1),
stateFilename: z.string().regex(DIGEST_FILENAME_PATTERN),
expiresAt: timestamp,
});
const sessionInputSchema = z.strictObject({
principal: z.strictObject({
issuer: text,
subject: text,
displayName: text.optional(),
roles: z.array(role).max(ROLES.length).refine(distinct),
permissions: z.array(permission).max(PERMISSIONS.length).refine(distinct),
isAdmin: z.boolean(),
}),
method: z.enum(["local", "oidc", "upstream"]),
remembered: z.boolean(),
userAuthRevision: z.number().int().positive().safe().optional(),
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
idleTtlMs: z.number().int().min(1).max(MAX_TTL_MS),
absoluteTtlMs: z.number().int().min(1).max(MAX_TTL_MS),
}).superRefine((input, context) => {
if (input.principal.isAdmin !== input.principal.roles.includes("admin")) {
context.addIssue({ code: "custom", message: "principal roles disagree" });
}
if (input.method === "local" && input.userAuthRevision === undefined) {
context.addIssue({ code: "custom", message: "local revision is required" });
}
if (input.method !== "local" && input.userAuthRevision !== undefined) {
context.addIssue({ code: "custom", message: "non-local revision is forbidden" });
}
});
const oidcStateInputSchema = z.strictObject({
nonce: z.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/),
codeVerifier: z.string().min(43).max(128).regex(/^[A-Za-z0-9._~-]+$/),
returnTo: z.literal("/"),
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
browserTransactionTransport: z.enum(["https", "loopback_http"]),
});
function canonicalRawValue(value: string): boolean {
if (typeof value !== "string" || !TOKEN_PATTERN.test(value)) return false;
try {
const bytes = Buffer.from(value, "base64url");
return bytes.length === TOKEN_BYTES && bytes.toString("base64url") === value;
} catch {
return false;
}
}
function digestFilename(rawValue: string): string {
return `${createHash("sha256").update(rawValue).digest("hex")}.json`;
}
function claimFilename(filename: string): string {
if (!DIGEST_FILENAME_PATTERN.test(filename)) throw invalid();
return filename.slice(0, -".json".length) + ".claim";
}
function oidcSlotFilename(index: number): string {
if (!Number.isInteger(index) || index < 0 || index >= OIDC_STATE_CAPACITY) throw invalid();
return `slot-${String(index).padStart(2, "0")}.json`;
}
function oidcSlotIndex(filename: string): number | undefined {
const match = OIDC_SLOT_FILENAME_PATTERN.exec(filename);
if (!match) return undefined;
const index = Number(match[1]);
return Number.isInteger(index) && index >= 0 && index < OIDC_STATE_CAPACITY ? index : undefined;
}
function parseSessionRecord(source: string): AuthSessionRecord {
try {
return sessionRecordSchema.parse(JSON.parse(source)) as AuthSessionRecord;
} catch {
throw invalid();
}
}
function parseOidcStateRecord(source: string): OidcStateRecord {
try {
return oidcStateRecordSchema.parse(JSON.parse(source)) as OidcStateRecord;
} catch {
throw invalid();
}
}
type OidcSlotRecord = z.infer<typeof oidcSlotRecordSchema>;
function parseOidcSlotRecord(source: string): OidcSlotRecord {
try {
return oidcSlotRecordSchema.parse(JSON.parse(source));
} catch {
throw invalid();
}
}
function parseWindowsRecord<T>(contents: Buffer, maximumBytes: number, parse: (source: string) => T): T {
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > maximumBytes) throw invalid();
try {
return parse(new TextDecoder("utf-8", { fatal: true }).decode(contents));
} catch {
throw invalid();
}
}
interface StoredOidcSlot {
filename: string;
index: number;
record: OidcSlotRecord;
}
function serialize(record: AuthSessionRecord | OidcStateRecord | OidcSlotRecord, maximumBytes: number): Buffer {
const contents = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
if (contents.length > maximumBytes) throw invalid();
return contents;
}
function dateMilliseconds(now: Date): number {
if (!(now instanceof Date) || !Number.isFinite(now.getTime())) throw invalid();
return now.getTime();
}
function isoAt(milliseconds: number): string {
if (!Number.isSafeInteger(milliseconds) || !Number.isFinite(milliseconds)) throw invalid();
try {
return new Date(milliseconds).toISOString();
} catch {
throw invalid();
}
}
function sessionExpired(record: AuthSessionRecord, nowMs: number): boolean {
return nowMs >= Date.parse(record.idleExpiresAt) || nowMs >= Date.parse(record.absoluteExpiresAt);
}
function oidcStateExpired(record: OidcStateRecord, nowMs: number): boolean {
return nowMs >= Date.parse(record.expiresAt);
}
function equalRoleSets(left: readonly Role[], right: readonly Role[]): boolean {
if (!distinct(left) || !distinct(right) || left.length !== right.length) return false;
if (!left.every((value) => ROLES.includes(value)) || !right.every((value) => ROLES.includes(value))) return false;
return [...left].sort().every((value, index) => value === [...right].sort()[index]);
}
function validLocalUser(user: LocalSessionUser | undefined, record: AuthSessionRecord): boolean {
return user !== undefined && user.enabled === true && Number.isSafeInteger(user.authRevision)
&& user.authRevision > 0 && user.authRevision === record.userAuthRevision
&& equalRoleSets(user.roles, record.roles);
}
async function recordIsCurrent(record: AuthSessionRecord, validity: AuthSessionValidity | undefined): Promise<boolean> {
// A root-only store remains useful for creation/diagnostics, but is intentionally incapable
// of authenticating a principal. Task 8 must supply config and local-registry dependencies.
if (!validity) return false;
if (record.method === "local" && validity.currentLocalUser) {
const current = await validity.currentLocalUser(record.subject);
return typeof current.revision === "string" && current.revision === record.authConfigRevision
&& validLocalUser(current.user, record);
}
const revision = await validity.currentAuthConfigRevision();
if (typeof revision !== "string" || revision !== record.authConfigRevision) return false;
if (record.method !== "local") return true;
return validity.findLocalUser === undefined ? false : validLocalUser(await validity.findLocalUser(record.subject), record);
}
const locks = new Map<string, Promise<void>>();
async function withLock<T>(key: string, operation: () => Promise<T>): Promise<T> {
const previous = locks.get(key) ?? Promise.resolve();
let release: (() => void) | undefined;
const current = new Promise<void>((resolve) => { release = resolve; });
locks.set(key, current);
await previous;
try {
return await operation();
} finally {
release?.();
if (locks.get(key) === current) locks.delete(key);
}
}
function lockKey(root: string, directory: "sessions" | "oidc", filename: string): string {
return `${root}\0${directory}\0${filename}`;
}
/** Derive a one-way, domain-separated 256-bit CSRF value without persisting it. */
export function deriveCsrfToken(sessionToken: string): string {
if (!canonicalRawValue(sessionToken)) throw invalid();
try {
const sessionBytes = Buffer.from(sessionToken, "base64url");
return Buffer.from(hkdfSync("sha256", sessionBytes, EMPTY_HKDF_SALT, CSRF_CONTEXT, TOKEN_BYTES))
.toString("base64url");
} catch {
throw invalid();
}
}
export function createFileAuthSessionStore(
root: string,
validity?: AuthSessionValidity,
options: FileAuthSessionStoreOptions = {},
): AuthSessionStore {
const oidcStateCapacity = options.oidcStateCapacity ?? OIDC_STATE_CAPACITY;
if (!Number.isInteger(oidcStateCapacity) || oidcStateCapacity < 1 || oidcStateCapacity > OIDC_STATE_CAPACITY) {
throw invalid();
}
const rawStorage = process.platform === "win32"
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
: options.posixStorageBridge ?? createPosixAuthStorageBridge();
// A malformed or failed helper must be indistinguishable from any other storage failure to
// callers. This also keeps narrow test seams from accidentally exposing transport details.
const storage: WindowsAuthStorageBridge = {
validateRoot: async (value) => { try { await rawStorage.validateRoot(value); } catch { throw invalid(); } },
ensureLayout: async (value) => { try { await rawStorage.ensureLayout(value); } catch { throw invalid(); } },
readAuthConfig: (value) => { try { return rawStorage.readAuthConfig(value); } catch { throw invalid(); } },
readLocalUsers: async (value) => { try { return await rawStorage.readLocalUsers(value); } catch { throw invalid(); } },
create: async (...args) => { try { return await rawStorage.create(...args); } catch { throw invalid(); } },
read: async (...args) => { try { return await rawStorage.read(...args); } catch { throw invalid(); } },
replace: async (...args) => { try { await rawStorage.replace(...args); } catch { throw invalid(); } },
remove: async (...args) => { try { return await rawStorage.remove(...args); } catch { throw invalid(); } },
list: async (...args) => { try { return await rawStorage.list(...args); } catch { throw invalid(); } },
listPage: async (...args) => { try { return await rawStorage.listPage(...args); } catch { throw invalid(); } },
claimConsume: async (...args) => { try { return await rawStorage.claimConsume(...args); } catch { throw invalid(); } },
readClaim: async (...args) => { try { return await rawStorage.readClaim(...args); } catch { throw invalid(); } },
removeClaim: async (...args) => { try { return await rawStorage.removeClaim(...args); } catch { throw invalid(); } },
};
let sessionPruneCursor: string | undefined;
function requiredStorage(): WindowsAuthStorageBridge {
if (!storage) throw invalid();
return storage;
}
async function ordinarySessionPage(after: string | undefined): Promise<SessionDirectoryPage> {
const page = await requiredStorage().listPage(root, "sessions", after, MAX_SESSION_PRUNE_ENTRIES);
if (!page || !Array.isArray(page.entries) || typeof page.more !== "boolean") throw invalid();
return { entries: page.entries.map((entry) => entry.name), more: page.more };
}
function nextSessionPruneCursor(page: SessionDirectoryPage, after: string | undefined): string | undefined {
if (!Array.isArray(page.entries) || typeof page.more !== "boolean"
|| page.entries.length > MAX_SESSION_PRUNE_ENTRIES) throw invalid();
const seen = new Set<string>();
let previous = after;
for (const filename of page.entries) {
if (!DIGEST_FILENAME_PATTERN.test(filename) || seen.has(filename)
|| (previous !== undefined && filename <= previous)) throw invalid();
seen.add(filename);
previous = filename;
}
if (!page.more) return undefined;
if (page.entries.length !== MAX_SESSION_PRUNE_ENTRIES || previous === undefined || previous === after) throw invalid();
return previous;
}
async function pruneOrdinarySessions(nowMs: number): Promise<number> {
const after = sessionPruneCursor;
const page = await ordinarySessionPage(after);
const next = nextSessionPruneCursor(page, after);
let removed = 0;
const bridge = requiredStorage();
for (const filename of page.entries) {
const contents = await bridge.read(root, "sessions", filename);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs) && await bridge.remove(root, "sessions", filename)) removed += 1;
}
sessionPruneCursor = next;
return removed;
}
async function oidcStorageEntries(): Promise<string[]> {
const entries = (await requiredStorage().list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES)).map((entry) => entry.name);
if (entries.length > MAX_OIDC_STORAGE_ENTRIES) throw invalid();
if (entries.some((entry) => !DIGEST_FILENAME_PATTERN.test(entry)
&& !CLAIM_FILENAME_PATTERN.test(entry) && oidcSlotIndex(entry) === undefined)) throw invalid();
return entries;
}
async function storedOidcSlots(suppliedEntries?: string[]): Promise<StoredOidcSlot[]> {
const entries = suppliedEntries ?? await oidcStorageEntries();
const slots: StoredOidcSlot[] = [];
const stateFilenames = new Set<string>();
for (const filename of entries) {
const index = oidcSlotIndex(filename);
if (index === undefined) continue;
const contents = await requiredStorage().read(root, "oidc", filename);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
if (stateFilenames.has(record.stateFilename)) throw invalid();
stateFilenames.add(record.stateFilename);
slots.push({ filename, index, record });
}
return slots;
}
async function removeOidcSlot(slot: StoredOidcSlot): Promise<void> {
const current = await requiredStorage().read(root, "oidc", slot.filename);
if (!current) return;
const record = parseWindowsRecord(current, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
if (record.stateFilename !== slot.record.stateFilename || record.expiresAt !== slot.record.expiresAt
|| !await requiredStorage().remove(root, "oidc", slot.filename)) throw invalid();
}
async function releaseOidcSlot(index: number | undefined, stateFilename: string): Promise<void> {
if (index === undefined) return;
const filename = oidcSlotFilename(index);
const slot = (await storedOidcSlots([filename]))[0];
if (!slot || slot.record.stateFilename !== stateFilename) throw invalid();
await removeOidcSlot(slot);
}
async function reserveOidcSlot(stateFilename: string, expiresAt: string): Promise<number> {
const entries = await oidcStorageEntries();
const slots = await storedOidcSlots(entries);
const representedStates = new Set(slots.map((slot) => slot.record.stateFilename));
const legacyStates = new Set<string>();
for (const entry of entries) {
const filename = CLAIM_FILENAME_PATTERN.test(entry)
? `${entry.slice(0, -".claim".length)}.json`
: entry;
if (DIGEST_FILENAME_PATTERN.test(filename) && !representedStates.has(filename)) legacyStates.add(filename);
}
const availableSlotCount = oidcStateCapacity - legacyStates.size;
if (availableSlotCount <= 0) throw new OidcStateCapacityError();
const occupied = new Set(slots.map((slot) => slot.index));
const record: OidcSlotRecord = { version: 1, stateFilename, expiresAt };
const contents = serialize(record, MAX_OIDC_SLOT_RECORD_BYTES);
for (let index = 0; index < availableSlotCount; index += 1) {
if (occupied.has(index)) continue;
const filename = oidcSlotFilename(index);
const created = await requiredStorage().create(root, "oidc", filename, contents);
if (created) return index;
}
throw new OidcStateCapacityError();
}
async function createSession(input: SessionCreateInput, now = new Date()): Promise<CreatedAuthSession> {
const nowMs = dateMilliseconds(now);
let validated: z.infer<typeof sessionInputSchema>;
try {
validated = sessionInputSchema.parse(input);
} catch {
throw invalid();
}
const absoluteExpiresMs = nowMs + validated.absoluteTtlMs;
const idleExpiresMs = Math.min(nowMs + validated.idleTtlMs, absoluteExpiresMs);
if (!Number.isSafeInteger(absoluteExpiresMs) || !Number.isSafeInteger(idleExpiresMs)) throw invalid();
const record: AuthSessionRecord = {
version: 1,
issuer: validated.principal.issuer,
subject: validated.principal.subject,
...(validated.principal.displayName === undefined ? {} : { displayName: validated.principal.displayName }),
method: validated.method,
roles: [...validated.principal.roles],
permissions: [...validated.principal.permissions],
...(validated.userAuthRevision === undefined ? {} : { userAuthRevision: validated.userAuthRevision }),
authConfigRevision: validated.authConfigRevision,
remembered: validated.remembered,
createdAt: isoAt(nowMs),
lastSeenAt: isoAt(nowMs),
idleExpiresAt: isoAt(idleExpiresMs),
absoluteExpiresAt: isoAt(absoluteExpiresMs),
};
const contents = serialize(record, MAX_SESSION_RECORD_BYTES);
const bridge = requiredStorage();
for (let attempt = 0; attempt < 8; attempt += 1) {
const token = randomBytes(TOKEN_BYTES).toString("base64url");
const filename = digestFilename(token);
if (await bridge.create(root, "sessions", filename, contents)) {
return { token, csrfToken: deriveCsrfToken(token), record };
}
}
throw invalid();
}
async function resolveSession(
token: string,
now = new Date(),
requestValidity = validity,
): Promise<AuthSessionRecord | undefined> {
if (!canonicalRawValue(token)) return undefined;
const nowMs = dateMilliseconds(now);
const filename = digestFilename(token);
return withLock(lockKey(root, "sessions", filename), async () => {
const bridge = requiredStorage();
const contents = await bridge.read(root, "sessions", filename);
if (!contents) return undefined;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs)) {
await bridge.remove(root, "sessions", filename);
return undefined;
}
try {
if (await recordIsCurrent(record, requestValidity)) return record;
} catch (error) {
if (error instanceof AuthSessionOperationalError) throw error;
await bridge.remove(root, "sessions", filename);
throw invalid();
}
await bridge.remove(root, "sessions", filename);
return undefined;
});
}
async function touchSession(token: string, now = new Date()): Promise<void> {
if (!canonicalRawValue(token)) return;
const nowMs = dateMilliseconds(now);
const filename = digestFilename(token);
await withLock(lockKey(root, "sessions", filename), async () => {
const bridge = requiredStorage();
const contents = await bridge.read(root, "sessions", filename);
if (!contents) return;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs)) {
await bridge.remove(root, "sessions", filename);
return undefined;
}
const lastSeenMs = Date.parse(record.lastSeenAt);
if (nowMs <= lastSeenMs || nowMs - lastSeenMs < TOUCH_INTERVAL_MS) return;
const idleWindowMs = Date.parse(record.idleExpiresAt) - lastSeenMs;
if (idleWindowMs <= 0 || idleWindowMs > MAX_TTL_MS) throw invalid();
const touched: AuthSessionRecord = {
...record,
lastSeenAt: isoAt(nowMs),
idleExpiresAt: isoAt(Math.min(nowMs + idleWindowMs, Date.parse(record.absoluteExpiresAt))),
};
await bridge.replace(root, "sessions", filename, serialize(touched, MAX_SESSION_RECORD_BYTES));
});
}
async function revokeSession(token: string): Promise<void> {
if (!canonicalRawValue(token)) return;
const filename = digestFilename(token);
await withLock(lockKey(root, "sessions", filename), async () => {
await requiredStorage().remove(root, "sessions", filename);
});
}
async function pruneOidcStates(nowMs: number): Promise<number> {
const bridge = requiredStorage();
const oidcEntries = await bridge.list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES);
if (oidcEntries.length > MAX_OIDC_STORAGE_ENTRIES) throw invalid();
const stateNames = new Set(oidcEntries
.filter((entry) => DIGEST_FILENAME_PATTERN.test(entry.name))
.map((entry) => entry.name));
const claimEntries = new Map(oidcEntries
.filter((entry) => CLAIM_FILENAME_PATTERN.test(entry.name))
.map((entry) => [entry.name, entry]));
const slotEntries = oidcEntries.filter((entry) => oidcSlotIndex(entry.name) !== undefined);
if (stateNames.size + claimEntries.size + slotEntries.length !== oidcEntries.length) throw invalid();
let removed = 0;
for (const filename of stateNames) {
const claim = claimFilename(filename);
const contents = claimEntries.has(claim)
? await bridge.readClaim(root, filename)
: await bridge.read(root, "oidc", filename);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
if (oidcStateExpired(record, nowMs)) {
const didRemove = claimEntries.has(claim)
? await bridge.removeClaim(root, filename)
: await bridge.remove(root, "oidc", filename);
if (didRemove) removed += 1;
}
}
for (const [claim, entry] of claimEntries) {
const filename = `${claim.slice(0, -".claim".length)}.json`;
if (stateNames.has(filename)) continue;
if (nowMs >= entry.modifiedUnixMs + OIDC_STATE_TTL_MS
&& await bridge.remove(root, "oidc", claim)) removed += 1;
}
for (const entry of slotEntries) {
const contents = await bridge.read(root, "oidc", entry.name);
if (!contents) continue;
const slot = parseWindowsRecord(contents, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
if (nowMs < Date.parse(slot.expiresAt)) continue;
const claim = claimFilename(slot.stateFilename);
const stateContents = claimEntries.has(claim)
? await bridge.readClaim(root, slot.stateFilename)
: await bridge.read(root, "oidc", slot.stateFilename);
if (stateContents) {
const state = parseWindowsRecord(stateContents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
if (!oidcStateExpired(state, nowMs)) throw invalid();
const didRemove = claimEntries.has(claim)
? await bridge.removeClaim(root, slot.stateFilename)
: await bridge.remove(root, "oidc", slot.stateFilename);
if (didRemove) removed += 1;
}
if (!await bridge.remove(root, "oidc", entry.name)) throw invalid();
}
return removed;
}
async function createOidcState(input: OidcStateCreateInput, now = new Date()): Promise<CreatedOidcState> {
const nowMs = dateMilliseconds(now);
let validated: z.infer<typeof oidcStateInputSchema>;
try {
validated = oidcStateInputSchema.parse(input);
} catch {
throw invalid();
}
const expiresMs = nowMs + OIDC_STATE_TTL_MS;
if (!Number.isSafeInteger(expiresMs)) throw invalid();
return withLock(lockKey(root, "oidc", "capacity"), async () => {
await pruneOidcStates(nowMs);
for (let attempt = 0; attempt < 8; attempt += 1) {
const state = randomBytes(TOKEN_BYTES).toString("base64url");
const filename = digestFilename(state);
const capacitySlot = await reserveOidcSlot(filename, isoAt(expiresMs));
const record: OidcStateRecord = {
version: 1,
nonce: validated.nonce,
codeVerifier: validated.codeVerifier,
returnTo: validated.returnTo,
authConfigRevision: validated.authConfigRevision,
issuer: validated.issuer,
browserTransactionDigest: validated.browserTransactionDigest,
browserTransactionTransport: validated.browserTransactionTransport,
capacitySlot,
createdAt: isoAt(nowMs),
expiresAt: isoAt(expiresMs),
};
const contents = serialize(record, MAX_OIDC_STATE_RECORD_BYTES);
const created = await requiredStorage().create(root, "oidc", filename, contents);
if (created) return { state, record };
await releaseOidcSlot(capacitySlot, filename);
}
throw invalid();
});
}
async function consumeOidcState(state: string, now = new Date()): Promise<OidcStateRecord | undefined> {
if (!canonicalRawValue(state)) return undefined;
const nowMs = dateMilliseconds(now);
const filename = digestFilename(state);
return withLock(lockKey(root, "oidc", filename), async () => {
const contents = await requiredStorage().claimConsume(root, filename);
if (!contents) return undefined;
const record = parseWindowsRecord(contents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
await releaseOidcSlot(record.capacitySlot, filename);
return oidcStateExpired(record, nowMs) ? undefined : record;
});
}
async function prune(now = new Date()): Promise<number> {
const nowMs = dateMilliseconds(now);
// Cursor advancement is process-local, so concurrent timer/manual invocations must not
// observe the same page and strand a later page forever.
return await withLock(lockKey(root, "sessions", "maintenance"), async () =>
(await pruneOrdinarySessions(nowMs)) + (await pruneOidcStates(nowMs)));
}
return {
create: createSession,
resolve: resolveSession,
touch: touchSession,
revoke: revokeSession,
prune,
createOidcState,
consumeOidcState,
};
}