From ab61c1ad6dbb8768c143bea1db9ef82b3485dea7 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 16 Aug 2026 20:28:32 +0200 Subject: [PATCH] feat(auth): persist opaque remembered sessions --- backend/src/auth/session-store.ts | 767 ++++++++++++++++++++++++ backend/src/auth/types.ts | 28 + backend/test/auth-session-store.test.ts | 250 ++++++++ 3 files changed, 1045 insertions(+) create mode 100644 backend/src/auth/session-store.ts create mode 100644 backend/test/auth-session-store.test.ts diff --git a/backend/src/auth/session-store.ts b/backend/src/auth/session-store.ts new file mode 100644 index 00000000..0d9c706e --- /dev/null +++ b/backend/src/auth/session-store.ts @@ -0,0 +1,767 @@ +import { createHash, hkdfSync, randomBytes } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readSync, + readdirSync, + 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, Permission, Role } from "./types.js"; + +const TOKEN_BYTES = 32; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const DIGEST_FILENAME_PATTERN = /^[a-f0-9]{64}\.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_TTL_MS = 365 * 24 * 60 * 60 * 1000; +const OIDC_STATE_TTL_MS = 10 * 60 * 1000; +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: "/"; +} + +export interface CreatedOidcState { + state: string; + record: OidcStateRecord; +} + +export interface LocalSessionUser { + enabled: boolean; + authRevision: number; + roles: readonly Role[]; +} + +/** + * 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; +} + +export interface AuthSessionStore { + create(input: SessionCreateInput, now?: Date): Promise; + resolve(token: string, now?: Date): 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; +} + +interface FileIdentity { + dev: number; + ino: number; + size: number; + mtimeMs: number; +} + +interface DirectoryIdentity { + dev: number; + ino: number; + mode?: number; +} + +interface TrustedFile { + value: T; + identity: FileIdentity; +} + +interface StorageDirectories { + root: string; + sessions: string; + oidc: string; +} + +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("/"), + 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 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("/"), +}); + +function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && 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.mode === right.mode; +} + +function isNotFound(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; +} + +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 assertFilename(filename: string): void { + if (!DIGEST_FILENAME_PATTERN.test(filename)) 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 fileIdentity(info: Stats): FileIdentity { + if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 + || (info.mode & 0o7777) !== PRIVATE_FILE_MODE || info.size < 0) { + throw invalid(); + } + return { dev: info.dev, ino: info.ino, 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 (process.platform !== "win32" && (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid(); + return { + dev: info.dev, + ino: info.ino, + ...(process.platform === "win32" ? {} : { mode: info.mode & 0o7777 }), + }; +} + +function privateDirectory(path: string): void { + let created = false; + try { + lstatSync(path); + } catch (error) { + if (!isNotFound(error)) throw invalid(); + try { + mkdirSync(path, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); + chmodSync(path, PRIVATE_DIRECTORY_MODE); + created = true; + } catch { + throw invalid(); + } + } + try { + directoryIdentity(path); + } catch { + if (!created) throw invalid(); + throw invalid(); + } +} + +function storageDirectories(root: string): StorageDirectories { + if (typeof root !== "string" || root.length === 0 || root.includes("\0") + || !isAbsolute(root) || normalize(root) !== root) throw invalid(); + privateDirectory(root); + const sessions = join(root, "sessions"); + const oidc = join(root, "oidc"); + privateDirectory(sessions); + privateDirectory(oidc); + return { root, sessions, oidc }; +} + +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, +): 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); + 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); + 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); + const afterPath = fileIdentity(lstatSync(path) as Stats); + 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 (process.platform !== "win32" && (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid(); + return { + dev: info.dev, + ino: info.ino, + ...(process.platform === "win32" ? {} : { 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): boolean { + const path = filePath(directory, filename); + try { + const current = fileIdentity(lstatSync(path) as Stats); + if (expected && !sameFileIdentity(expected, current)) 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(); + } +} + +function serialize(record: AuthSessionRecord | OidcStateRecord, 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 { + if (!validity) return true; + const revision = await validity.currentAuthConfigRevision(); + if (typeof revision !== "string" || revision !== record.authConfigRevision) return false; + if (record.method !== "local") return true; + return 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): AuthSessionStore { + 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); + 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()): Promise { + if (!canonicalRawValue(token)) return undefined; + const nowMs = dateMilliseconds(now); + const filename = digestFilename(token); + return withLock(lockKey(root, "sessions", filename), async () => { + 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, validity)) return trusted.value; + } catch { + 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 () => { + 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 () => { + const directories = storageDirectories(root); + removeTrusted(directories.sessions, filename); + }); + } + + 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(); + const record: OidcStateRecord = { + version: 1, + nonce: validated.nonce, + codeVerifier: validated.codeVerifier, + returnTo: validated.returnTo, + createdAt: isoAt(nowMs), + expiresAt: isoAt(expiresMs), + }; + const contents = serialize(record, MAX_OIDC_STATE_RECORD_BYTES); + const directories = storageDirectories(root); + for (let attempt = 0; attempt < 8; attempt += 1) { + const state = randomBytes(TOKEN_BYTES).toString("base64url"); + if (writeExclusive(directories.oidc, digestFilename(state), contents)) return { state, record }; + } + 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 () => { + const directories = storageDirectories(root); + const trusted = readTrusted(directories.oidc, filename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord); + if (!trusted) return undefined; + removeTrusted(directories.oidc, filename, trusted.identity); + return oidcStateExpired(trusted.value, nowMs) ? undefined : trusted.value; + }); + } + + async function prune(now = new Date()): Promise { + const nowMs = dateMilliseconds(now); + const directories = storageDirectories(root); + let removed = 0; + const pruneDirectory = async ( + directory: string, + kind: "sessions" | "oidc", + maximumBytes: number, + parse: (source: string) => AuthSessionRecord | OidcStateRecord, + expired: (record: AuthSessionRecord | OidcStateRecord, timestamp: number) => boolean, + ): Promise => { + let entries: string[]; + try { + entries = readdirSync(directory); + } catch { + throw invalid(); + } + for (const filename of entries) { + if (!DIGEST_FILENAME_PATTERN.test(filename)) continue; + await withLock(lockKey(root, kind, filename), async () => { + const trusted = readTrusted(directory, filename, maximumBytes, parse); + if (trusted && expired(trusted.value, nowMs)) { + if (removeTrusted(directory, filename, trusted.identity)) removed += 1; + } + }); + } + }; + await pruneDirectory( + directories.sessions, + "sessions", + MAX_SESSION_RECORD_BYTES, + parseSessionRecord, + (record, timestamp) => sessionExpired(record as AuthSessionRecord, timestamp), + ); + await pruneDirectory( + directories.oidc, + "oidc", + MAX_OIDC_STATE_RECORD_BYTES, + parseOidcStateRecord, + (record, timestamp) => oidcStateExpired(record as OidcStateRecord, timestamp), + ); + return removed; + } + + return { + create: createSession, + resolve: resolveSession, + touch: touchSession, + revoke: revokeSession, + prune, + createOidcState, + consumeOidcState, + }; +} diff --git a/backend/src/auth/types.ts b/backend/src/auth/types.ts index a83bde4c..86b37787 100644 --- a/backend/src/auth/types.ts +++ b/backend/src/auth/types.ts @@ -54,3 +54,31 @@ export interface LoadedAuthConfig { export interface AuthenticationConfigProvider { current(): LoadedAuthConfig; } + +/** Durable, server-side representation of an opaque browser session. */ +export interface AuthSessionRecord { + version: 1; + issuer: string; + subject: string; + displayName?: string; + method: "local" | "oidc" | "upstream"; + roles: readonly Role[]; + permissions: readonly Permission[]; + userAuthRevision?: number; + authConfigRevision: string; + remembered: boolean; + createdAt: string; + lastSeenAt: string; + idleExpiresAt: string; + absoluteExpiresAt: string; +} + +/** Server-side OIDC callback material keyed by a separately generated opaque state value. */ +export interface OidcStateRecord { + version: 1; + nonce: string; + codeVerifier: string; + returnTo: "/"; + createdAt: string; + expiresAt: string; +} diff --git a/backend/test/auth-session-store.test.ts b/backend/test/auth-session-store.test.ts new file mode 100644 index 00000000..525c7e34 --- /dev/null +++ b/backend/test/auth-session-store.test.ts @@ -0,0 +1,250 @@ +import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + linkSync, + lstatSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { + createFileAuthSessionStore, + deriveCsrfToken, + type AuthSessionStore, + type SessionCreateInput, +} from "../src/auth/session-store.js"; + +const roots: string[] = []; +const base = new Date("2030-01-02T03:04:05.000Z"); +const revision = "a".repeat(64); + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function root(): string { + const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-session-")); + chmodSync(path, 0o700); + roots.push(path); + return path; +} + +function digestPath(rootPath: string, directory: "sessions" | "oidc", rawValue: string): string { + return join(rootPath, directory, `${createHash("sha256").update(rawValue).digest("hex")}.json`); +} + +async function create( + store: AuthSessionStore, + overrides: Partial = {}, + now = base, +) { + return store.create({ + principal: { + issuer: "local", + subject: "6ba7b810-9dad-4ed1-80b4-00c04fd430c8", + displayName: "Local administrator", + roles: ["admin"], + permissions: ["session.use", "settings.manage"], + isAdmin: true, + }, + method: "local", + remembered: true, + userAuthRevision: 7, + authConfigRevision: revision, + idleTtlMs: 10 * 60_000, + absoluteTtlMs: 30 * 60_000, + ...overrides, + }, now); +} + +async function expectStoreInvalid(operation: Promise): Promise { + await expect(operation).rejects.toThrow("auth_session_store_invalid"); +} + +describe("file-backed auth session store", () => { + test("creates 256-bit opaque tokens, digest-only files, and derived CSRF values", async () => { + const storageRoot = root(); + const store = createFileAuthSessionStore(storageRoot); + const first = await create(store); + const second = await create(store); + const path = digestPath(storageRoot, "sessions", first.token); + const contents = readFileSync(path, "utf8"); + + expect(first.token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(Buffer.from(first.token, "base64url")).toHaveLength(32); + expect(second.token).not.toBe(first.token); + expect(lstatSync(path).isFile()).toBe(true); + expect(path).toMatch(/[a-f0-9]{64}\.json$/); + expect(contents).not.toContain(first.token); + expect(contents).not.toContain(first.csrfToken); + expect(deriveCsrfToken(first.token)).toBe(first.csrfToken); + expect(first.csrfToken).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(first.csrfToken).not.toBe(first.token); + + if (process.platform !== "win32") { + expect(lstatSync(storageRoot).mode & 0o7777).toBe(0o700); + expect(lstatSync(join(storageRoot, "sessions")).mode & 0o7777).toBe(0o700); + expect(lstatSync(join(storageRoot, "oidc")).mode & 0o7777).toBe(0o700); + expect(lstatSync(path).mode & 0o7777).toBe(0o600); + } + }); + + test("survives a backend restart and respects idle and absolute expiry", async () => { + const storageRoot = root(); + const firstStore = createFileAuthSessionStore(storageRoot); + const created = await create(firstStore); + const restartedStore = createFileAuthSessionStore(storageRoot); + + await expect(restartedStore.resolve(created.token, new Date(base.getTime() + 9 * 60_000))) + .resolves.toMatchObject({ subject: created.record.subject, remembered: true }); + await expect(restartedStore.resolve(created.token, new Date(base.getTime() + 10 * 60_000))) + .resolves.toBeUndefined(); + expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false); + + const absolute = await create(restartedStore, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 10 * 60_000 }); + await expect(restartedStore.resolve(absolute.token, new Date(base.getTime() + 10 * 60_000))) + .resolves.toBeUndefined(); + }); + + test("touches at most once per five minutes and never extends absolute expiry", async () => { + const storageRoot = root(); + const store = createFileAuthSessionStore(storageRoot); + const created = await create(store); + const path = digestPath(storageRoot, "sessions", created.token); + const before = readFileSync(path, "utf8"); + + await store.touch(created.token, new Date(base.getTime() + 4 * 60_000)); + expect(readFileSync(path, "utf8")).toBe(before); + + await store.touch(created.token, new Date(base.getTime() + 5 * 60_000)); + await expect(store.resolve(created.token, new Date(base.getTime() + 5 * 60_000))).resolves.toMatchObject({ + lastSeenAt: "2030-01-02T03:09:05.000Z", + idleExpiresAt: "2030-01-02T03:19:05.000Z", + absoluteExpiresAt: "2030-01-02T03:34:05.000Z", + }); + }); + + test("revokes sessions and prunes expired session and OIDC-state records", async () => { + const storageRoot = root(); + const store = createFileAuthSessionStore(storageRoot); + const revoked = await create(store); + const expired = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 }); + const oidc = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base); + + await store.revoke(revoked.token); + await expect(store.resolve(revoked.token)).resolves.toBeUndefined(); + expect(existsSync(digestPath(storageRoot, "sessions", revoked.token))).toBe(false); + + await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(2); + expect(existsSync(digestPath(storageRoot, "sessions", expired.token))).toBe(false); + expect(existsSync(digestPath(storageRoot, "oidc", oidc.state))).toBe(false); + }); + + test("creates bounded OIDC state records that expire and are single-use", async () => { + const storageRoot = root(); + const store = createFileAuthSessionStore(storageRoot); + const created = await store.createOidcState({ + nonce: "n".repeat(43), + codeVerifier: "v".repeat(43), + returnTo: "/", + }, base); + const path = digestPath(storageRoot, "oidc", created.state); + + expect(created.state).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(readFileSync(path, "utf8")).not.toContain(created.state); + await expect(store.consumeOidcState(created.state, new Date(base.getTime() + 9 * 60_000))) + .resolves.toMatchObject({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }); + await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined(); + + const expired = await store.createOidcState({ nonce: "x".repeat(43), codeVerifier: "y".repeat(43), returnTo: "/" }, base); + await expect(store.consumeOidcState(expired.state, new Date(base.getTime() + 10 * 60_000))) + .resolves.toBeUndefined(); + }); + + test.skipIf(process.platform === "win32")("refuses symlinked and hard-linked session records", async () => { + const storageRoot = root(); + const store = createFileAuthSessionStore(storageRoot); + const symlinked = await create(store); + const symlinkPath = digestPath(storageRoot, "sessions", symlinked.token); + const target = `${symlinkPath}.target`; + renameSync(symlinkPath, target); + symlinkSync(target, symlinkPath); + await expectStoreInvalid(store.resolve(symlinked.token)); + + const hardLinked = await create(store); + const hardLinkPath = digestPath(storageRoot, "sessions", hardLinked.token); + linkSync(hardLinkPath, `${hardLinkPath}.link`); + await expectStoreInvalid(store.resolve(hardLinked.token)); + }); + + test("refuses malformed and oversized session records without disclosing their contents", async () => { + const storageRoot = root(); + const store = createFileAuthSessionStore(storageRoot); + const malformed = await create(store); + const malformedPath = digestPath(storageRoot, "sessions", malformed.token); + writeFileSync(malformedPath, "{}", { encoding: "utf8", mode: 0o600 }); + chmodSync(malformedPath, 0o600); + await expectStoreInvalid(store.resolve(malformed.token)); + + const oversized = await create(store); + const oversizedPath = digestPath(storageRoot, "sessions", oversized.token); + writeFileSync(oversizedPath, "#".repeat(20_000), { encoding: "utf8", mode: 0o600 }); + chmodSync(oversizedPath, 0o600); + await expectStoreInvalid(store.resolve(oversized.token)); + }); + + test("revokes on config, local-user, revision, enabled, or role mismatch before returning", async () => { + const storageRoot = root(); + let currentRevision = revision; + let localUser: { enabled: boolean; authRevision: number; roles: readonly ("user" | "admin")[] } | undefined = { + enabled: true, + authRevision: 7, + roles: ["admin"], + }; + const store = createFileAuthSessionStore(storageRoot, { + currentAuthConfigRevision: () => currentRevision, + findLocalUser: async () => localUser, + }); + + const configChanged = await create(store); + currentRevision = "b".repeat(64); + await expect(store.resolve(configChanged.token)).resolves.toBeUndefined(); + expect(existsSync(digestPath(storageRoot, "sessions", configChanged.token))).toBe(false); + + currentRevision = revision; + for (const mismatch of [ + undefined, + { enabled: false, authRevision: 7, roles: ["admin"] as const }, + { enabled: true, authRevision: 8, roles: ["admin"] as const }, + { enabled: true, authRevision: 7, roles: ["user"] as const }, + ]) { + localUser = mismatch; + const session = await create(store); + await expect(store.resolve(session.token)).resolves.toBeUndefined(); + expect(existsSync(digestPath(storageRoot, "sessions", session.token))).toBe(false); + } + }); + + test("serializes concurrent resolve and revoke without resurrecting a record", async () => { + const storageRoot = root(); + const firstStore = createFileAuthSessionStore(storageRoot); + const secondStore = createFileAuthSessionStore(storageRoot); + const created = await create(firstStore); + + await Promise.all([ + ...Array.from({ length: 8 }, () => firstStore.resolve(created.token)), + ...Array.from({ length: 8 }, () => secondStore.revoke(created.token)), + ]); + + await expect(firstStore.resolve(created.token)).resolves.toBeUndefined(); + expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false); + }); +});