feat(auth): persist opaque remembered sessions

This commit is contained in:
2026-08-16 20:28:32 +02:00
parent b80a2cc1e9
commit ab61c1ad6d
3 changed files with 1045 additions and 0 deletions
+767
View File
@@ -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<string>;
findLocalUser(subject: string): LocalSessionUser | undefined | Promise<LocalSessionUser | undefined>;
}
export interface AuthSessionStore {
create(input: SessionCreateInput, now?: Date): Promise<CreatedAuthSession>;
resolve(token: string, now?: Date): Promise<AuthSessionRecord | undefined>;
touch(token: string, now?: Date): Promise<void>;
revoke(token: string): Promise<void>;
prune(now?: Date): Promise<number>;
createOidcState(input: OidcStateCreateInput, now?: Date): Promise<CreatedOidcState>;
consumeOidcState(state: string, now?: Date): Promise<OidcStateRecord | undefined>;
}
interface FileIdentity {
dev: number;
ino: number;
size: number;
mtimeMs: number;
}
interface DirectoryIdentity {
dev: number;
ino: number;
mode?: number;
}
interface TrustedFile<T> {
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 = <T>(items: readonly T[]): boolean => new Set(items).size === items.length;
const sessionRecordSchema = z.strictObject({
version: z.literal(1),
issuer: text,
subject: text,
displayName: text.optional(),
method: z.enum(["local", "oidc", "upstream"]),
roles: z.array(role).max(ROLES.length).refine(distinct),
permissions: z.array(permission).max(PERMISSIONS.length).refine(distinct),
userAuthRevision: z.number().int().positive().safe().optional(),
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
remembered: z.boolean(),
createdAt: timestamp,
lastSeenAt: timestamp,
idleExpiresAt: timestamp,
absoluteExpiresAt: timestamp,
}).superRefine((record, context) => {
const createdAt = Date.parse(record.createdAt);
const lastSeenAt = Date.parse(record.lastSeenAt);
const idleExpiresAt = Date.parse(record.idleExpiresAt);
const absoluteExpiresAt = Date.parse(record.absoluteExpiresAt);
if (lastSeenAt < createdAt || idleExpiresAt < lastSeenAt || idleExpiresAt > absoluteExpiresAt
|| absoluteExpiresAt < createdAt || absoluteExpiresAt - createdAt > MAX_TTL_MS) {
context.addIssue({ code: "custom", message: "invalid session lifetime" });
}
if (record.method === "local" && record.userAuthRevision === undefined) {
context.addIssue({ code: "custom", message: "local revision is required" });
}
if (record.method !== "local" && record.userAuthRevision !== undefined) {
context.addIssue({ code: "custom", message: "non-local revision is forbidden" });
}
});
const oidcStateRecordSchema = z.strictObject({
version: z.literal(1),
nonce: z.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/),
codeVerifier: z.string().min(43).max(128).regex(/^[A-Za-z0-9._~-]+$/),
returnTo: z.literal("/"),
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<T>(
directory: string,
filename: string,
maximumBytes: number,
parse: (source: string) => T,
): TrustedFile<T> | 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<boolean> {
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<string, Promise<void>>();
async function withLock<T>(key: string, operation: () => Promise<T>): Promise<T> {
const previous = locks.get(key) ?? Promise.resolve();
let release: (() => void) | undefined;
const current = new Promise<void>((resolve) => { release = resolve; });
locks.set(key, current);
await previous;
try {
return await operation();
} finally {
release?.();
if (locks.get(key) === current) locks.delete(key);
}
}
function lockKey(root: string, directory: "sessions" | "oidc", filename: string): string {
return `${root}\0${directory}\0${filename}`;
}
/** Derive a one-way, domain-separated 256-bit CSRF value without persisting it. */
export function deriveCsrfToken(sessionToken: string): string {
if (!canonicalRawValue(sessionToken)) throw invalid();
try {
const sessionBytes = Buffer.from(sessionToken, "base64url");
return Buffer.from(hkdfSync("sha256", sessionBytes, EMPTY_HKDF_SALT, CSRF_CONTEXT, TOKEN_BYTES))
.toString("base64url");
} catch {
throw invalid();
}
}
export function createFileAuthSessionStore(root: string, validity?: AuthSessionValidity): AuthSessionStore {
async function createSession(input: SessionCreateInput, now = new Date()): Promise<CreatedAuthSession> {
const nowMs = dateMilliseconds(now);
let validated: z.infer<typeof sessionInputSchema>;
try {
validated = sessionInputSchema.parse(input);
} catch {
throw invalid();
}
const absoluteExpiresMs = nowMs + validated.absoluteTtlMs;
const idleExpiresMs = Math.min(nowMs + validated.idleTtlMs, absoluteExpiresMs);
if (!Number.isSafeInteger(absoluteExpiresMs) || !Number.isSafeInteger(idleExpiresMs)) throw invalid();
const record: AuthSessionRecord = {
version: 1,
issuer: validated.principal.issuer,
subject: validated.principal.subject,
...(validated.principal.displayName === undefined ? {} : { displayName: validated.principal.displayName }),
method: validated.method,
roles: [...validated.principal.roles],
permissions: [...validated.principal.permissions],
...(validated.userAuthRevision === undefined ? {} : { userAuthRevision: validated.userAuthRevision }),
authConfigRevision: validated.authConfigRevision,
remembered: validated.remembered,
createdAt: isoAt(nowMs),
lastSeenAt: isoAt(nowMs),
idleExpiresAt: isoAt(idleExpiresMs),
absoluteExpiresAt: isoAt(absoluteExpiresMs),
};
const contents = serialize(record, MAX_SESSION_RECORD_BYTES);
const 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<AuthSessionRecord | undefined> {
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<void> {
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<void> {
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<CreatedOidcState> {
const nowMs = dateMilliseconds(now);
let validated: z.infer<typeof oidcStateInputSchema>;
try {
validated = oidcStateInputSchema.parse(input);
} catch {
throw invalid();
}
const expiresMs = nowMs + OIDC_STATE_TTL_MS;
if (!Number.isSafeInteger(expiresMs)) throw invalid();
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<OidcStateRecord | undefined> {
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<number> {
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<void> => {
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,
};
}
+28
View File
@@ -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;
}
+250
View File
@@ -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<SessionCreateInput> = {},
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<unknown>): Promise<void> {
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);
});
});