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