import { createHash, hkdfSync, randomBytes } from "node:crypto"; import { accessSync, closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, linkSync, mkdirSync, openSync, opendirSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, } from "node:fs"; import type { Stats } from "node:fs"; import { dirname, isAbsolute, join, normalize } from "node:path"; import { z } from "zod"; import type { PrincipalContext } from "./principal.js"; import type { AuthSessionRecord, OidcStateRecord, OidcTransactionTransport, Permission, Role, } from "./types.js"; import { 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 PRIVATE_DIRECTORY_MODE = 0o700; const PRIVATE_FILE_MODE = 0o600; 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; // A separate scan ceiling bounds directory-walk CPU and the duplicate-detection set without // reviving the former 512-record availability ceiling. const MAX_SESSION_DIRECTORY_PAGE_SCAN_ENTRIES = 16_384; 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", "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; findLocalUser?(subject: string): LocalSessionUser | undefined | Promise; currentLocalUser?(subject: string): CurrentLocalSessionUser | Promise; } export interface AuthSessionStore { create(input: SessionCreateInput, now?: Date): Promise; resolve(token: string, now?: Date, validity?: AuthSessionValidity): Promise; touch(token: string, now?: Date): Promise; revoke(token: string): Promise; prune(now?: Date): Promise; createOidcState(input: OidcStateCreateInput, now?: Date): Promise; consumeOidcState(state: string, now?: Date): Promise; } /** Narrow test seam for the native Windows tht-backed storage adaptor. */ export interface FileAuthSessionStoreOptions { windowsStorageBridge?: WindowsAuthStorageBridge; /** Test-only capacity seam; production always uses the fixed 64-state bound. */ oidcStateCapacity?: number; } interface FileIdentity { dev: number; ino: number; uid: number; size: number; mtimeMs: number; } interface DirectoryIdentity { dev: number; ino: number; uid: number; mode?: number; } interface DirectoryScanIdentity extends DirectoryIdentity { mtimeMs: number; ctimeMs: number; } interface TrustedFile { value: T; identity: FileIdentity; } interface StorageDirectories { root: string; sessions: string; oidc: string; } 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 = (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 sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.size === right.size && left.mtimeMs === right.mtimeMs; } function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean { return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode; } function sameDirectoryScanIdentity(left: DirectoryScanIdentity, right: DirectoryScanIdentity): boolean { return sameDirectoryIdentity(left, right) && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; } function isNotFound(error: unknown): boolean { return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; } function isAlreadyExists(error: unknown): boolean { return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST"; } 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 assertFilename(filename: string): void { if (!DIGEST_FILENAME_PATTERN.test(filename) && !CLAIM_FILENAME_PATTERN.test(filename) && oidcSlotIndex(filename) === undefined) throw invalid(); } function filePath(directory: string, filename: string): string { assertFilename(filename); const path = join(directory, filename); if (dirname(path) !== directory) throw invalid(); return path; } function ownerId(): number { const getEffectiveUserId = process.geteuid; if (typeof getEffectiveUserId !== "function") throw invalid(); const euid = getEffectiveUserId(); if (!Number.isSafeInteger(euid) || euid < 0) throw invalid(); return euid; } function fileIdentity(info: Stats, expectedLinks = 1): FileIdentity { if (!info.isFile() || info.isSymbolicLink() || info.nlink !== expectedLinks || info.uid !== ownerId() || (info.mode & 0o7777) !== PRIVATE_FILE_MODE || info.size < 0) { throw invalid(); } return { dev: info.dev, ino: info.ino, uid: info.uid, size: info.size, mtimeMs: info.mtimeMs }; } function directoryIdentity(path: string): DirectoryIdentity { const info = lstatSync(path) as Stats; if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(path) !== path) throw invalid(); if (info.uid !== ownerId() || (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid(); return { dev: info.dev, ino: info.ino, uid: info.uid, mode: info.mode & 0o7777, }; } function directoryScanIdentity(path: string): DirectoryScanIdentity { const identity = directoryIdentity(path); const info = lstatSync(path) as Stats; if (!info.isDirectory() || info.isSymbolicLink() || info.dev !== identity.dev || info.ino !== identity.ino || info.uid !== identity.uid || (info.mode & 0o7777) !== identity.mode || !Number.isFinite(info.mtimeMs) || !Number.isFinite(info.ctimeMs)) throw invalid(); return { ...identity, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs }; } interface SessionRootAncestor { path: string; descriptor: number; dev: number; ino: number; uid: number; } function validateSessionRootSyntax(root: string): void { if (process.platform === "win32" || typeof root !== "string" || root.length === 0 || root.includes("\0") || /\p{Cc}/u.test(root) || !isAbsolute(root) || normalize(root) !== root) throw invalid(); } function sameAncestor(ancestor: SessionRootAncestor, info: Stats): boolean { return info.isDirectory() && !info.isSymbolicLink() && ancestor.dev === info.dev && ancestor.ino === info.ino && ancestor.uid === info.uid; } function withSessionRootPreflight(root: string, use: (exists: boolean) => T): T { validateSessionRootSyntax(root); const ancestors: SessionRootAncestor[] = []; try { const components = root.split("/").filter((component) => component.length > 0); let current = "/"; for (let index = -1; index < components.length; index += 1) { if (index >= 0) current = join(current, components[index]!); let info: Stats; try { info = lstatSync(current) as Stats; } catch (error) { if (!isNotFound(error) || index !== components.length - 1) throw invalid(); accessSync(dirname(current), constants.W_OK | constants.X_OK); for (const ancestor of ancestors) { const observed = lstatSync(ancestor.path) as Stats; if (!sameAncestor(ancestor, observed) || realpathSync(ancestor.path) !== ancestor.path) throw invalid(); } const result = use(false); for (const ancestor of ancestors) { const observed = lstatSync(ancestor.path) as Stats; if (!sameAncestor(ancestor, observed) || realpathSync(ancestor.path) !== ancestor.path) throw invalid(); } return result; } if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(current) !== current) throw invalid(); const descriptor = openSync(current, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); const opened = fstatSync(descriptor) as Stats; if (!opened.isDirectory() || opened.dev !== info.dev || opened.ino !== info.ino || opened.uid !== info.uid) { closeSync(descriptor); throw invalid(); } ancestors.push({ path: current, descriptor, dev: info.dev, ino: info.ino, uid: info.uid }); } directoryIdentity(root); const result = use(true); for (const ancestor of ancestors) { const observed = lstatSync(ancestor.path) as Stats; if (!sameAncestor(ancestor, observed) || realpathSync(ancestor.path) !== ancestor.path) throw invalid(); } return result; } catch { throw invalid(); } finally { for (const ancestor of ancestors.reverse()) { try { closeSync(ancestor.descriptor); } catch { /* preflight has already failed closed */ } } } } function privateDirectory(path: string): void { withSessionRootPreflight(path, (exists) => { if (exists) return; try { mkdirSync(path, { recursive: false, mode: PRIVATE_DIRECTORY_MODE }); } catch (error) { if (!isAlreadyExists(error)) throw invalid(); directoryIdentity(path); return; } const descriptor = openSync(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); try { fchmodSync(descriptor, PRIVATE_DIRECTORY_MODE); const opened = fstatSync(descriptor) as Stats; const current = directoryIdentity(path); if (opened.dev !== current.dev || opened.ino !== current.ino || opened.uid !== current.uid || (opened.mode & 0o7777) !== current.mode) throw invalid(); } finally { try { closeSync(descriptor); } catch { /* creation already fails closed */ } } }); } /** Side-effect-free POSIX validator shared by runtime storage and static diagnostics. */ export function validateAuthSessionRoot(root: string): void { try { withSessionRootPreflight(root, () => undefined); } catch { throw invalid(); } } function storageDirectories(root: string): StorageDirectories { // Native Windows calls must dispatch to the tht DACL-capable bridge before reaching this // POSIX-only helper. Keep this guard so an un-routed caller cannot fall back to chmod. validateSessionRootSyntax(root); privateDirectory(root); validateAuthSessionRoot(root); const sessions = join(root, "sessions"); const oidc = join(root, "oidc"); privateDirectory(sessions); privateDirectory(oidc); return { root, sessions, oidc }; } function boundedDirectoryNames(directory: string, maximumEntries: number): string[] { if (!Number.isInteger(maximumEntries) || maximumEntries < 1) throw invalid(); let handle: ReturnType | undefined; try { const before = directoryIdentity(directory); handle = opendirSync(directory); const names: string[] = []; while (names.length <= maximumEntries) { const entry = handle.readSync(); if (entry === null) { handle.closeSync(); handle = undefined; if (!sameDirectoryIdentity(before, directoryIdentity(directory))) throw invalid(); return names; } names.push(entry.name); } throw invalid(); } catch { throw invalid(); } finally { if (handle !== undefined) { try { handle.closeSync(); } catch { /* the operation is already fail-closed */ } } } } /** * Return the next lexical page without retaining a complete directory listing. Every direct * child is structurally validated during the bounded scan, so an unsafe entry cannot hide after * a full page of ordinary sessions. */ function boundedSessionDirectoryPage( directory: string, after: string | undefined, maximumEntries: number, ): SessionDirectoryPage { if (!Number.isInteger(maximumEntries) || maximumEntries < 1 || (after !== undefined && !DIGEST_FILENAME_PATTERN.test(after))) throw invalid(); let handle: ReturnType | undefined; try { const before = directoryScanIdentity(directory); handle = opendirSync(directory); const seen = new Set(); const selected: string[] = []; let scanned = 0; while (true) { const entry = handle.readSync(); if (entry === null) { handle.closeSync(); handle = undefined; if (!sameDirectoryScanIdentity(before, directoryScanIdentity(directory))) throw invalid(); selected.sort((left, right) => left < right ? -1 : left > right ? 1 : 0); const more = selected.length > maximumEntries; return { entries: more ? selected.slice(0, maximumEntries) : selected, more }; } scanned += 1; if (scanned > MAX_SESSION_DIRECTORY_PAGE_SCAN_ENTRIES) throw invalid(); const filename = entry.name; if (!DIGEST_FILENAME_PATTERN.test(filename) || seen.has(filename)) throw invalid(); seen.add(filename); let info: Stats; try { info = lstatSync(filePath(directory, filename)) as Stats; } catch (error) { throw invalid(); } fileIdentity(info); if (after !== undefined && filename <= after) continue; appendBoundedSessionFilename(selected, filename, maximumEntries + 1); } } catch { throw invalid(); } finally { if (handle !== undefined) { try { handle.closeSync(); } catch { /* the operation is already fail-closed */ } } } } function appendBoundedSessionFilename(names: string[], filename: string, maximumEntries: number): void { if (names.length < maximumEntries) { names.push(filename); return; } let greatest = 0; for (let index = 1; index < names.length; index += 1) { if (names[index] > names[greatest]) greatest = index; } if (filename < names[greatest]) names[greatest] = filename; } function openDirectory(directory: string): number | undefined { if (process.platform === "win32") return undefined; return openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); } function syncDirectory(directory: string): void { if (process.platform === "win32") return; let descriptor: number | undefined; try { descriptor = openDirectory(directory); if (descriptor === undefined) throw invalid(); fsyncSync(descriptor); } catch { throw invalid(); } finally { if (descriptor !== undefined) { try { closeSync(descriptor); } catch { /* converted to a sanitized failure above */ } } } } function writeFully(descriptor: number, contents: Buffer): void { let offset = 0; while (offset < contents.length) { const written = writeSync(descriptor, contents, offset, contents.length - offset); if (written <= 0) throw invalid(); offset += written; } } function readTrusted( directory: string, filename: string, maximumBytes: number, parse: (source: string) => T, expectedLinks = 1, ): TrustedFile | undefined { const path = filePath(directory, filename); let directoryDescriptor: number | undefined; let descriptor: number | undefined; try { const beforeDirectory = directoryIdentity(directory); const beforePath = lstatSync(path) as Stats; const before = fileIdentity(beforePath, expectedLinks); if (before.size > maximumBytes) throw invalid(); directoryDescriptor = openDirectory(directory); const openedDirectory = directoryDescriptor === undefined ? beforeDirectory : directoryIdentityFromDescriptor(directoryDescriptor); if (!sameDirectoryIdentity(beforeDirectory, openedDirectory)) throw invalid(); descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); const opened = fileIdentity(fstatSync(descriptor) as Stats, expectedLinks); if (!sameFileIdentity(before, opened) || opened.size > maximumBytes) throw invalid(); const contents = Buffer.allocUnsafe(maximumBytes + 1); let offset = 0; while (offset < contents.length) { const read = readSync(descriptor, contents, offset, contents.length - offset, null); if (read === 0) break; offset += read; } if (offset > maximumBytes) throw invalid(); const after = fileIdentity(fstatSync(descriptor) as Stats, expectedLinks); const afterPath = fileIdentity(lstatSync(path) as Stats, expectedLinks); const afterDirectory = directoryIdentity(directory); const afterOpenedDirectory = directoryDescriptor === undefined ? afterDirectory : directoryIdentityFromDescriptor(directoryDescriptor); if (!sameFileIdentity(opened, after) || !sameFileIdentity(after, afterPath) || !sameDirectoryIdentity(beforeDirectory, afterDirectory) || !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid(); const source = new TextDecoder("utf-8", { fatal: true }).decode(contents.subarray(0, offset)); return { value: parse(source), identity: after }; } catch (error) { if (isNotFound(error)) return undefined; throw invalid(); } finally { if (descriptor !== undefined) { try { closeSync(descriptor); } catch { /* descriptor is no longer trusted */ } } if (directoryDescriptor !== undefined) { try { closeSync(directoryDescriptor); } catch { /* descriptor is no longer trusted */ } } } } function directoryIdentityFromDescriptor(descriptor: number): DirectoryIdentity { const info = fstatSync(descriptor) as Stats; if (!info.isDirectory() || info.isSymbolicLink()) throw invalid(); if (info.uid !== ownerId() || (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid(); return { dev: info.dev, ino: info.ino, uid: info.uid, mode: info.mode & 0o7777, }; } function writeExclusive(directory: string, filename: string, contents: Buffer): boolean { const path = filePath(directory, filename); let descriptor: number | undefined; try { descriptor = openSync( path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), PRIVATE_FILE_MODE, ); fchmodSync(descriptor, PRIVATE_FILE_MODE); writeFully(descriptor, contents); fsyncSync(descriptor); const written = fileIdentity(fstatSync(descriptor) as Stats); if (written.size !== contents.length) throw invalid(); closeSync(descriptor); descriptor = undefined; syncDirectory(directory); return true; } catch (error) { if (isNotFound(error)) throw invalid(); if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") return false; throw invalid(); } finally { if (descriptor !== undefined) { try { closeSync(descriptor); } catch { /* best effort only */ } try { unlinkSync(path); } catch { /* only our exclusive temporary record can remain */ } } } } function replaceTrusted( directory: string, filename: string, expected: FileIdentity, contents: Buffer, ): void { const path = filePath(directory, filename); const temporary = join(directory, `.${filename}.${randomBytes(12).toString("hex")}.tmp`); let descriptor: number | undefined; try { descriptor = openSync( temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), PRIVATE_FILE_MODE, ); fchmodSync(descriptor, PRIVATE_FILE_MODE); writeFully(descriptor, contents); fsyncSync(descriptor); const temporaryInfo = fileIdentity(fstatSync(descriptor) as Stats); if (temporaryInfo.size !== contents.length) throw invalid(); closeSync(descriptor); descriptor = undefined; const current = fileIdentity(lstatSync(path) as Stats); if (!sameFileIdentity(expected, current)) throw invalid(); renameSync(temporary, path); syncDirectory(directory); } catch { throw invalid(); } finally { if (descriptor !== undefined) { try { closeSync(descriptor); } catch { /* the failing write remains untrusted */ } } try { unlinkSync(temporary); } catch { /* rename or no creation: nothing to remove */ } } } function removeTrusted( directory: string, filename: string, expected?: FileIdentity, expectedLinks = 1, ): boolean { const path = filePath(directory, filename); try { const beforeDirectory = directoryIdentity(directory); const current = fileIdentity(lstatSync(path) as Stats, expectedLinks); if (expected && !sameFileIdentity(expected, current)) throw invalid(); // Revalidate both names immediately before unlink. On POSIX unlink never follows a final // symlink, and this closes the observable replacement window before that operation. const finalDirectory = directoryIdentity(directory); const final = fileIdentity(lstatSync(path) as Stats, expectedLinks); if (!sameDirectoryIdentity(beforeDirectory, finalDirectory) || !sameFileIdentity(current, final) || (expected !== undefined && !sameFileIdentity(expected, final))) throw invalid(); unlinkSync(path); syncDirectory(directory); return true; } catch (error) { if (isNotFound(error)) return false; throw invalid(); } } 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; function parseOidcSlotRecord(source: string): OidcSlotRecord { try { return oidcSlotRecordSchema.parse(JSON.parse(source)); } catch { throw invalid(); } } function parseWindowsRecord(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 OidcStateClaim { state: TrustedFile; claimIdentity: FileIdentity; } interface StoredOidcSlot { filename: string; index: number; record: OidcSlotRecord; identity?: FileIdentity; } function inspectOidcStateClaim( directory: string, filename: string, ): OidcStateClaim | "orphan" | undefined { const claimedFilename = claimFilename(filename); let claimInfo: Stats; try { claimInfo = lstatSync(filePath(directory, claimedFilename)) as Stats; } catch (error) { if (isNotFound(error)) return undefined; throw invalid(); } let sourceInfo: Stats; try { sourceInfo = lstatSync(filePath(directory, filename)) as Stats; } catch (error) { if (!isNotFound(error)) throw invalid(); // A consumer may have just unlinked the source and not yet removed its claim. Do not // remove that orphan here: doing so could make the winning consumer fail closed after it // has read the state. prune() removes abandoned orphan claims after the state lifetime. fileIdentity(claimInfo, 1); return "orphan"; } const sourceIdentity = fileIdentity(sourceInfo, 2); const claimIdentity = fileIdentity(claimInfo, 2); if (!sameFileIdentity(sourceIdentity, claimIdentity)) throw invalid(); const state = readTrusted(directory, filename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord, 2); if (!state || !sameFileIdentity(sourceIdentity, state.identity)) throw invalid(); return { state, claimIdentity }; } function oidcClaimExists(directory: string, filename: string): boolean { const claimedFilename = claimFilename(filename); try { const info = lstatSync(filePath(directory, claimedFilename)) as Stats; if (info.nlink !== 1 && info.nlink !== 2) throw invalid(); fileIdentity(info, info.nlink); return true; } catch (error) { if (isNotFound(error)) return false; throw invalid(); } } /** * Claim a state by creating a hard link with a deterministic digest-only name. link(2) / NTFS * CreateHardLink fails if another process has already installed that name, unlike rename which * can overwrite the previous claimant. A crash leaves the pair unavailable until expiry/prune. */ function claimOidcState(directory: string, filename: string): OidcStateClaim | undefined { // A competing process may be partway through consumption. It has already won and must be // the only process allowed to deserialize the record; this process simply treats it as used. if (oidcClaimExists(directory, filename)) return undefined; const statePath = filePath(directory, filename); const claimedFilename = claimFilename(filename); const claimedPath = filePath(directory, claimedFilename); let before: FileIdentity; try { before = fileIdentity(lstatSync(statePath) as Stats); } catch (error) { if (isNotFound(error)) return undefined; // A winner can install its hard-link after the first claim check and before this state // identity check. That process owns the state; this contender must fail closed as used. if (oidcClaimExists(directory, filename)) return undefined; throw invalid(); } try { linkSync(statePath, claimedPath); } catch (error) { if (isNotFound(error)) return undefined; if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") return undefined; throw invalid(); } try { const sourceIdentity = fileIdentity(lstatSync(statePath) as Stats, 2); const claimIdentity = fileIdentity(lstatSync(claimedPath) as Stats, 2); if (!sameFileIdentity(before, sourceIdentity) || !sameFileIdentity(sourceIdentity, claimIdentity)) throw invalid(); const state = readTrusted(directory, filename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord, 2); if (!state || !sameFileIdentity(state.identity, sourceIdentity)) throw invalid(); return { state, claimIdentity }; } catch { throw invalid(); } } function removeClaimedOidcState(directory: string, filename: string, claim: OidcStateClaim): void { if (!removeTrusted(directory, filename, claim.state.identity, 2)) throw invalid(); if (!removeTrusted(directory, claimFilename(filename), claim.claimIdentity)) throw invalid(); } 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 { // 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>(); async function withLock(key: string, operation: () => Promise): Promise { const previous = locks.get(key) ?? Promise.resolve(); let release: (() => void) | undefined; const current = new Promise((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 windowsStorage = process.platform === "win32" ? options.windowsStorageBridge ?? createWindowsAuthStorageBridge() : undefined; let sessionPruneCursor: string | undefined; function requiredWindowsStorage(): WindowsAuthStorageBridge { if (windowsStorage === undefined) throw invalid(); return windowsStorage; } async function ordinarySessionPage(after: string | undefined): Promise { if (process.platform !== "win32") { return boundedSessionDirectoryPage(storageDirectories(root).sessions, after, MAX_SESSION_PRUNE_ENTRIES); } const page = await requiredWindowsStorage().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(); 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 { const after = sessionPruneCursor; const page = await ordinarySessionPage(after); const next = nextSessionPruneCursor(page, after); let removed = 0; if (process.platform === "win32") { const bridge = requiredWindowsStorage(); 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; } } else { const directory = storageDirectories(root).sessions; for (const filename of page.entries) { await withLock(lockKey(root, "sessions", filename), async () => { const trusted = readTrusted(directory, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord); if (trusted && sessionExpired(trusted.value, nowMs) && removeTrusted(directory, filename, trusted.identity)) removed += 1; }); } } sessionPruneCursor = next; return removed; } async function oidcStorageEntries(): Promise { const entries = process.platform === "win32" ? (await requiredWindowsStorage().list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES)).map((entry) => entry.name) : boundedDirectoryNames(storageDirectories(root).oidc, MAX_OIDC_STORAGE_ENTRIES); 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 { const entries = suppliedEntries ?? await oidcStorageEntries(); const slots: StoredOidcSlot[] = []; const stateFilenames = new Set(); for (const filename of entries) { const index = oidcSlotIndex(filename); if (index === undefined) continue; if (process.platform === "win32") { const contents = await requiredWindowsStorage().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 }); continue; } let trusted: TrustedFile | undefined; let lastError: unknown; for (const retryDelayMs of [0, 1, 2, 4, 8, 16, 32]) { if (retryDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); try { trusted = readTrusted( storageDirectories(root).oidc, filename, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord, ); lastError = undefined; break; } catch (error) { lastError = error; } } if (lastError !== undefined) throw invalid(); if (!trusted) continue; if (stateFilenames.has(trusted.value.stateFilename)) throw invalid(); stateFilenames.add(trusted.value.stateFilename); slots.push({ filename, index, record: trusted.value, identity: trusted.identity }); } return slots; } async function removeOidcSlot(slot: StoredOidcSlot): Promise { if (process.platform === "win32") { const current = await requiredWindowsStorage().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 requiredWindowsStorage().remove(root, "oidc", slot.filename)) throw invalid(); return; } if (!slot.identity || !removeTrusted(storageDirectories(root).oidc, slot.filename, slot.identity)) throw invalid(); } async function releaseOidcSlot(index: number | undefined, stateFilename: string): Promise { 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 { const entries = await oidcStorageEntries(); const slots = await storedOidcSlots(entries); const representedStates = new Set(slots.map((slot) => slot.record.stateFilename)); const legacyStates = new Set(); 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 = process.platform === "win32" ? await requiredWindowsStorage().create(root, "oidc", filename, contents) : writeExclusive(storageDirectories(root).oidc, filename, contents); if (created) return index; } throw new OidcStateCapacityError(); } async function createSession(input: SessionCreateInput, now = new Date()): Promise { const nowMs = dateMilliseconds(now); let validated: z.infer; 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); if (process.platform === "win32") { const bridge = requiredWindowsStorage(); for (let attempt = 0; attempt < 8; attempt += 1) { const token = randomBytes(TOKEN_BYTES).toString("base64url"); if (await bridge.create(root, "sessions", digestFilename(token), contents)) { return { token, csrfToken: deriveCsrfToken(token), record }; } } throw invalid(); } const directories = storageDirectories(root); for (let attempt = 0; attempt < 8; attempt += 1) { const token = randomBytes(TOKEN_BYTES).toString("base64url"); const filename = digestFilename(token); if (writeExclusive(directories.sessions, filename, contents)) { return { token, csrfToken: deriveCsrfToken(token), record }; } } throw invalid(); } async function resolveSession( token: string, now = new Date(), requestValidity = validity, ): Promise { if (!canonicalRawValue(token)) return undefined; const nowMs = dateMilliseconds(now); const filename = digestFilename(token); return withLock(lockKey(root, "sessions", filename), async () => { if (process.platform === "win32") { const bridge = requiredWindowsStorage(); 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; } const directories = storageDirectories(root); const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord); if (!trusted) return undefined; if (sessionExpired(trusted.value, nowMs)) { removeTrusted(directories.sessions, filename, trusted.identity); return undefined; } try { if (await recordIsCurrent(trusted.value, requestValidity)) return trusted.value; } catch (error) { if (error instanceof AuthSessionOperationalError) throw error; removeTrusted(directories.sessions, filename, trusted.identity); throw invalid(); } removeTrusted(directories.sessions, filename, trusted.identity); return undefined; }); } async function touchSession(token: string, now = new Date()): Promise { if (!canonicalRawValue(token)) return; const nowMs = dateMilliseconds(now); const filename = digestFilename(token); await withLock(lockKey(root, "sessions", filename), async () => { if (process.platform === "win32") { const bridge = requiredWindowsStorage(); 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; } 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)); return; } const directories = storageDirectories(root); const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord); if (!trusted) return; if (sessionExpired(trusted.value, nowMs)) { removeTrusted(directories.sessions, filename, trusted.identity); return; } const lastSeenMs = Date.parse(trusted.value.lastSeenAt); if (nowMs <= lastSeenMs || nowMs - lastSeenMs < TOUCH_INTERVAL_MS) return; const idleWindowMs = Date.parse(trusted.value.idleExpiresAt) - lastSeenMs; if (idleWindowMs <= 0 || idleWindowMs > MAX_TTL_MS) throw invalid(); const touched: AuthSessionRecord = { ...trusted.value, lastSeenAt: isoAt(nowMs), idleExpiresAt: isoAt(Math.min(nowMs + idleWindowMs, Date.parse(trusted.value.absoluteExpiresAt))), }; replaceTrusted( directories.sessions, filename, trusted.identity, serialize(touched, MAX_SESSION_RECORD_BYTES), ); }); } async function revokeSession(token: string): Promise { if (!canonicalRawValue(token)) return; const filename = digestFilename(token); await withLock(lockKey(root, "sessions", filename), async () => { if (process.platform === "win32") { await requiredWindowsStorage().remove(root, "sessions", filename); return; } const directories = storageDirectories(root); removeTrusted(directories.sessions, filename); }); } async function pruneOidcStates(nowMs: number): Promise { if (process.platform === "win32") { const bridge = requiredWindowsStorage(); 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; } const directory = storageDirectories(root).oidc; const oidcEntries = boundedDirectoryNames(directory, MAX_OIDC_STORAGE_ENTRIES); const stateFilenames = new Set(oidcEntries.filter((entry) => DIGEST_FILENAME_PATTERN.test(entry))); let removed = 0; for (const filename of stateFilenames) { await withLock(lockKey(root, "oidc", filename), async () => { const claim = inspectOidcStateClaim(directory, filename); if (claim === "orphan") return; if (claim) { if (oidcStateExpired(claim.state.value, nowMs)) { removeClaimedOidcState(directory, filename, claim); removed += 1; } return; } const trusted = readTrusted(directory, filename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord); if (trusted && oidcStateExpired(trusted.value, nowMs) && removeTrusted(directory, filename, trusted.identity)) removed += 1; }); } for (const claimedFilename of oidcEntries) { if (!CLAIM_FILENAME_PATTERN.test(claimedFilename)) continue; const filename = `${claimedFilename.slice(0, -".claim".length)}.json`; if (stateFilenames.has(filename)) continue; await withLock(lockKey(root, "oidc", filename), async () => { const claim = inspectOidcStateClaim(directory, filename); if (claim !== "orphan") return; const claimIdentity = fileIdentity(lstatSync(filePath(directory, claimedFilename)) as Stats); if (nowMs >= claimIdentity.mtimeMs + OIDC_STATE_TTL_MS && removeTrusted(directory, claimedFilename, claimIdentity)) removed += 1; }); } const slots = await storedOidcSlots(oidcEntries.filter((entry) => oidcSlotIndex(entry) !== undefined)); for (const slot of slots) { if (nowMs < Date.parse(slot.record.expiresAt)) continue; await withLock(lockKey(root, "oidc", slot.record.stateFilename), async () => { const claim = inspectOidcStateClaim(directory, slot.record.stateFilename); if (claim && claim !== "orphan") { if (!oidcStateExpired(claim.state.value, nowMs)) throw invalid(); removeClaimedOidcState(directory, slot.record.stateFilename, claim); removed += 1; } else if (!claim) { const trusted = readTrusted( directory, slot.record.stateFilename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord, ); if (trusted) { if (!oidcStateExpired(trusted.value, nowMs)) throw invalid(); if (removeTrusted(directory, slot.record.stateFilename, trusted.identity)) removed += 1; } } await removeOidcSlot(slot); }); } return removed; } async function createOidcState(input: OidcStateCreateInput, now = new Date()): Promise { const nowMs = dateMilliseconds(now); let validated: z.infer; 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 = process.platform === "win32" ? await requiredWindowsStorage().create(root, "oidc", filename, contents) : writeExclusive(storageDirectories(root).oidc, filename, contents); if (created) return { state, record }; await releaseOidcSlot(capacitySlot, filename); } throw invalid(); }); } async function consumeOidcState(state: string, now = new Date()): Promise { if (!canonicalRawValue(state)) return undefined; const nowMs = dateMilliseconds(now); const filename = digestFilename(state); return withLock(lockKey(root, "oidc", filename), async () => { if (process.platform === "win32") { const contents = await requiredWindowsStorage().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; } const directories = storageDirectories(root); const claim = claimOidcState(directories.oidc, filename); // An installed claim belongs to another process/store instance. Only the process which // created the hard link is allowed to receive the record. if (!claim) return undefined; if (oidcStateExpired(claim.state.value, nowMs)) { removeClaimedOidcState(directories.oidc, filename, claim); await releaseOidcSlot(claim.state.value.capacitySlot, filename); return undefined; } removeClaimedOidcState(directories.oidc, filename, claim); await releaseOidcSlot(claim.state.value.capacitySlot, filename); return claim.state.value; }); } async function prune(now = new Date()): Promise { 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, }; }