fix(auth): bound OIDC initiation and transport

This commit is contained in:
2026-08-17 07:03:19 +02:00
parent 8573500121
commit bdabecbb63
15 changed files with 747 additions and 111 deletions
+228 -25
View File
@@ -31,12 +31,15 @@ const TOKEN_BYTES = 32;
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const DIGEST_FILENAME_PATTERN = /^[a-f0-9]{64}\.json$/;
const CLAIM_FILENAME_PATTERN = /^[a-f0-9]{64}\.claim$/;
const OIDC_SLOT_FILENAME_PATTERN = /^slot-(\d{2})\.json$/;
const PRIVATE_DIRECTORY_MODE = 0o700;
const PRIVATE_FILE_MODE = 0o600;
const MAX_SESSION_RECORD_BYTES = 16 * 1024;
const MAX_OIDC_STATE_RECORD_BYTES = 8 * 1024;
const MAX_OIDC_SLOT_RECORD_BYTES = 512;
const MAX_TTL_MS = 365 * 24 * 60 * 60 * 1000;
const OIDC_STATE_TTL_MS = 10 * 60 * 1000;
const OIDC_STATE_CAPACITY = 64;
const TOUCH_INTERVAL_MS = 5 * 60 * 1000;
const CSRF_CONTEXT = Buffer.from("thothii-csrf-v1", "utf8");
const EMPTY_HKDF_SALT = Buffer.alloc(0);
@@ -96,6 +99,12 @@ export class AuthSessionOperationalError extends 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.
@@ -119,6 +128,8 @@ export interface AuthSessionStore {
/** Narrow test seam for the native Windows tht-backed storage adaptor. */
export interface FileAuthSessionStoreOptions {
windowsStorageBridge?: WindowsAuthStorageBridge;
/** Test-only capacity seam; production always uses the fixed 64-state bound. */
oidcStateCapacity?: number;
}
interface FileIdentity {
@@ -194,6 +205,7 @@ const oidcStateRecordSchema = z.strictObject({
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}$/),
capacitySlot: z.number().int().min(0).max(OIDC_STATE_CAPACITY - 1).optional(),
createdAt: timestamp,
expiresAt: timestamp,
}).superRefine((record, context) => {
@@ -202,6 +214,11 @@ const oidcStateRecordSchema = z.strictObject({
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,
@@ -269,8 +286,21 @@ function claimFilename(filename: string): string {
return filename.slice(0, -".json".length) + ".claim";
}
function oidcSlotFilename(index: number): string {
if (!Number.isInteger(index) || index < 0 || index >= OIDC_STATE_CAPACITY) throw invalid();
return `slot-${String(index).padStart(2, "0")}.json`;
}
function oidcSlotIndex(filename: string): number | undefined {
const match = OIDC_SLOT_FILENAME_PATTERN.exec(filename);
if (!match) return undefined;
const index = Number(match[1]);
return Number.isInteger(index) && index >= 0 && index < OIDC_STATE_CAPACITY ? index : undefined;
}
function assertFilename(filename: string): void {
if (!DIGEST_FILENAME_PATTERN.test(filename) && !CLAIM_FILENAME_PATTERN.test(filename)) throw invalid();
if (!DIGEST_FILENAME_PATTERN.test(filename) && !CLAIM_FILENAME_PATTERN.test(filename)
&& oidcSlotIndex(filename) === undefined) throw invalid();
}
function filePath(directory: string, filename: string): string {
@@ -555,6 +585,16 @@ function parseOidcStateRecord(source: string): OidcStateRecord {
}
}
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 {
@@ -569,6 +609,13 @@ interface OidcStateClaim {
claimIdentity: FileIdentity;
}
interface StoredOidcSlot {
filename: string;
index: number;
record: OidcSlotRecord;
identity?: FileIdentity;
}
function inspectOidcStateClaim(
directory: string,
filename: string,
@@ -660,7 +707,7 @@ function removeClaimedOidcState(directory: string, filename: string, claim: Oidc
if (!removeTrusted(directory, claimFilename(filename), claim.claimIdentity)) throw invalid();
}
function serialize(record: AuthSessionRecord | OidcStateRecord, maximumBytes: number): Buffer {
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;
@@ -752,6 +799,10 @@ export function createFileAuthSessionStore(
validity?: AuthSessionValidity,
options: FileAuthSessionStoreOptions = {},
): AuthSessionStore {
const oidcStateCapacity = options.oidcStateCapacity ?? OIDC_STATE_CAPACITY;
if (!Number.isInteger(oidcStateCapacity) || oidcStateCapacity < 1 || oidcStateCapacity > OIDC_STATE_CAPACITY) {
throw invalid();
}
const windowsStorage = process.platform === "win32"
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
: undefined;
@@ -761,6 +812,110 @@ export function createFileAuthSessionStore(
return windowsStorage;
}
async function oidcStorageEntries(): Promise<string[]> {
const entries = process.platform === "win32"
? (await requiredWindowsStorage().list(root, "oidc")).map((entry) => entry.name)
: (() => {
try {
return readdirSync(storageDirectories(root).oidc);
} catch {
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;
if (process.platform === "win32") {
const contents = await requiredWindowsStorage().read(root, "oidc", filename);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
if (stateFilenames.has(record.stateFilename)) throw invalid();
stateFilenames.add(record.stateFilename);
slots.push({ filename, index, record });
continue;
}
let trusted: TrustedFile<OidcSlotRecord> | undefined;
let lastError: unknown;
for (const retryDelayMs of [0, 1, 2, 4, 8, 16, 32]) {
if (retryDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
try {
trusted = readTrusted(
storageDirectories(root).oidc,
filename,
MAX_OIDC_SLOT_RECORD_BYTES,
parseOidcSlotRecord,
);
lastError = undefined;
break;
} catch (error) {
lastError = error;
}
}
if (lastError !== undefined) throw invalid();
if (!trusted) continue;
if (stateFilenames.has(trusted.value.stateFilename)) throw invalid();
stateFilenames.add(trusted.value.stateFilename);
slots.push({ filename, index, record: trusted.value, identity: trusted.identity });
}
return slots;
}
async function removeOidcSlot(slot: StoredOidcSlot): Promise<void> {
if (process.platform === "win32") {
const current = await requiredWindowsStorage().read(root, "oidc", slot.filename);
if (!current) return;
const record = parseWindowsRecord(current, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
if (record.stateFilename !== slot.record.stateFilename || record.expiresAt !== slot.record.expiresAt
|| !await requiredWindowsStorage().remove(root, "oidc", slot.filename)) throw invalid();
return;
}
if (!slot.identity || !removeTrusted(storageDirectories(root).oidc, slot.filename, slot.identity)) throw invalid();
}
async function releaseOidcSlot(index: number | undefined, stateFilename: string): Promise<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 = process.platform === "win32"
? await requiredWindowsStorage().create(root, "oidc", filename, contents)
: writeExclusive(storageDirectories(root).oidc, filename, contents);
if (created) return index;
}
throw new OidcStateCapacityError();
}
async function createSession(input: SessionCreateInput, now = new Date()): Promise<CreatedAuthSession> {
const nowMs = dateMilliseconds(now);
let validated: z.infer<typeof sessionInputSchema>;
@@ -931,32 +1086,33 @@ export function createFileAuthSessionStore(
}
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,
authConfigRevision: validated.authConfigRevision,
issuer: validated.issuer,
browserTransactionDigest: validated.browserTransactionDigest,
createdAt: isoAt(nowMs),
expiresAt: isoAt(expiresMs),
};
const contents = serialize(record, MAX_OIDC_STATE_RECORD_BYTES);
if (process.platform === "win32") {
const bridge = requiredWindowsStorage();
return withLock(lockKey(root, "oidc", "capacity"), async () => {
await prune(now);
for (let attempt = 0; attempt < 8; attempt += 1) {
const state = randomBytes(TOKEN_BYTES).toString("base64url");
if (await bridge.create(root, "oidc", digestFilename(state), contents)) return { state, record };
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,
capacitySlot,
createdAt: isoAt(nowMs),
expiresAt: isoAt(expiresMs),
};
const contents = serialize(record, MAX_OIDC_STATE_RECORD_BYTES);
const created = process.platform === "win32"
? await requiredWindowsStorage().create(root, "oidc", filename, contents)
: writeExclusive(storageDirectories(root).oidc, filename, contents);
if (created) return { state, record };
await releaseOidcSlot(capacitySlot, filename);
}
throw invalid();
}
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> {
@@ -968,6 +1124,7 @@ export function createFileAuthSessionStore(
const contents = await requiredWindowsStorage().claimConsume(root, filename);
if (!contents) return undefined;
const record = parseWindowsRecord(contents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
await releaseOidcSlot(record.capacitySlot, filename);
return oidcStateExpired(record, nowMs) ? undefined : record;
}
const directories = storageDirectories(root);
@@ -977,9 +1134,11 @@ export function createFileAuthSessionStore(
if (!claim) return undefined;
if (oidcStateExpired(claim.state.value, nowMs)) {
removeClaimedOidcState(directories.oidc, filename, claim);
await releaseOidcSlot(claim.state.value.capacitySlot, filename);
return undefined;
}
removeClaimedOidcState(directories.oidc, filename, claim);
await releaseOidcSlot(claim.state.value.capacitySlot, filename);
return claim.state.value;
});
}
@@ -1002,7 +1161,8 @@ export function createFileAuthSessionStore(
const claimEntries = new Map(oidcEntries
.filter((entry) => CLAIM_FILENAME_PATTERN.test(entry.name))
.map((entry) => [entry.name, entry]));
if (stateNames.size + claimEntries.size !== oidcEntries.length) throw invalid();
const slotEntries = oidcEntries.filter((entry) => oidcSlotIndex(entry.name) !== undefined);
if (stateNames.size + claimEntries.size + slotEntries.length !== oidcEntries.length) throw invalid();
for (const filename of stateNames) {
const claim = claimFilename(filename);
const contents = claimEntries.has(claim)
@@ -1023,6 +1183,25 @@ export function createFileAuthSessionStore(
if (nowMs >= entry.modifiedUnixMs + OIDC_STATE_TTL_MS
&& await bridge.remove(root, "oidc", claim)) removed += 1;
}
for (const entry of slotEntries) {
const contents = await bridge.read(root, "oidc", entry.name);
if (!contents) continue;
const slot = parseWindowsRecord(contents, MAX_OIDC_SLOT_RECORD_BYTES, parseOidcSlotRecord);
if (nowMs < Date.parse(slot.expiresAt)) continue;
const claim = claimFilename(slot.stateFilename);
const stateContents = claimEntries.has(claim)
? await bridge.readClaim(root, slot.stateFilename)
: await bridge.read(root, "oidc", slot.stateFilename);
if (stateContents) {
const state = parseWindowsRecord(stateContents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
if (!oidcStateExpired(state, nowMs)) throw invalid();
const didRemove = claimEntries.has(claim)
? await bridge.removeClaim(root, slot.stateFilename)
: await bridge.remove(root, "oidc", slot.stateFilename);
if (didRemove) removed += 1;
}
if (!await bridge.remove(root, "oidc", entry.name)) throw invalid();
}
return removed;
}
const directories = storageDirectories(root);
@@ -1103,6 +1282,30 @@ export function createFileAuthSessionStore(
}
});
}
const slots = await storedOidcSlots(oidcEntries.filter((entry) => oidcSlotIndex(entry) !== undefined));
for (const slot of slots) {
if (nowMs < Date.parse(slot.record.expiresAt)) continue;
await withLock(lockKey(root, "oidc", slot.record.stateFilename), async () => {
const claim = inspectOidcStateClaim(directories.oidc, slot.record.stateFilename);
if (claim && claim !== "orphan") {
if (!oidcStateExpired(claim.state.value, nowMs)) throw invalid();
removeClaimedOidcState(directories.oidc, slot.record.stateFilename, claim);
removed += 1;
} else if (!claim) {
const trusted = readTrusted(
directories.oidc,
slot.record.stateFilename,
MAX_OIDC_STATE_RECORD_BYTES,
parseOidcStateRecord,
);
if (trusted) {
if (!oidcStateExpired(trusted.value, nowMs)) throw invalid();
if (removeTrusted(directories.oidc, slot.record.stateFilename, trusted.identity)) removed += 1;
}
}
await removeOidcSlot(slot);
});
}
return removed;
}