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
+93 -2
View File
@@ -8,6 +8,7 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
@@ -60,6 +61,7 @@ import {
createFileAuthSessionStore,
deriveCsrfToken,
type AuthSessionStore,
type FileAuthSessionStoreOptions,
type SessionCreateInput,
} from "../src/auth/session-store.js";
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
@@ -104,11 +106,11 @@ function claimPath(rootPath: string, rawState: string): string {
return join(rootPath, "oidc", `${createHash("sha256").update(rawState).digest("hex")}.claim`);
}
function validStore(storageRoot: string): AuthSessionStore {
function validStore(storageRoot: string, options: FileAuthSessionStoreOptions = {}): AuthSessionStore {
return createFileAuthSessionStore(storageRoot, {
currentAuthConfigRevision: () => revision,
findLocalUser: async () => validLocalUser,
});
}, options);
}
async function create(
@@ -180,6 +182,52 @@ async function isolatedOidcConsumer(storageRoot: string, state: string): Promise
return { start: () => child.send("consume"), result };
}
async function isolatedOidcCreator(storageRoot: string, attempts: number): Promise<{
start(): void;
result: Promise<number>;
}> {
const child = fork(new URL("./fixtures/oidc-state-creator.mts", import.meta.url), [], {
cwd: process.cwd(),
execArgv: ["--import", "tsx"],
env: {
...process.env,
THT_TEST_SESSION_ROOT: storageRoot,
THT_TEST_OIDC_ATTEMPTS: String(attempts),
THT_TEST_OIDC_NOW: base.toISOString(),
THT_TEST_OIDC_CAPACITY: "8",
},
silent: true,
});
const ready = new Promise<void>((resolve, reject) => {
child.once("message", (message) => {
if (message === "ready") resolve();
else reject(new Error("OIDC creator did not become ready"));
});
child.once("error", reject);
child.once("exit", (code) => {
if (code !== null && code !== 0) reject(new Error("OIDC creator exited before ready"));
});
});
const result = new Promise<number>((resolve, reject) => {
let outcome: { created: unknown; failed?: unknown; error?: unknown } | undefined;
child.on("message", (message) => {
if (message && typeof message === "object" && "created" in message) {
outcome = message as { created: unknown; failed?: unknown; error?: unknown };
}
});
child.on("error", reject);
child.on("exit", (code) => {
if (code !== 0 || outcome?.failed === true || typeof outcome?.created !== "number") {
reject(new Error(`OIDC creator failed: ${String(outcome?.error)}`));
} else {
resolve(outcome.created);
}
});
});
await ready;
return { start: () => child.send("create"), result };
}
describe("file-backed auth session store", () => {
test("fails closed and revokes a session when constructed without validity dependencies", async () => {
const storageRoot = root();
@@ -301,6 +349,49 @@ describe("file-backed auth session store", () => {
.resolves.toBeUndefined();
});
test("rejects an OIDC-state flood without deleting any valid live state", async () => {
const storageRoot = root();
const store = validStore(storageRoot, { oidcStateCapacity: 8 });
const created = await Promise.all(Array.from({ length: 8 }, (_unused, index) =>
store.createOidcState(oidcInput(`n${String(index).padStart(42, "0")}`, `v${String(index).padStart(42, "0")}`), base)));
await expect(store.createOidcState(oidcInput("x".repeat(43), "y".repeat(43)), base))
.rejects.toThrow("auth_oidc_state_capacity");
await expect(store.consumeOidcState(created[0].state, new Date(base.getTime() + 60_000)))
.resolves.toMatchObject({ nonce: "n" + "0".repeat(42) });
expect(created.slice(1).every(({ state }) => existsSync(digestPath(storageRoot, "oidc", state)))).toBe(true);
});
test("prunes expired OIDC states before admitting a new transaction at capacity", async () => {
const storageRoot = root();
const store = validStore(storageRoot, { oidcStateCapacity: 8 });
const expired = await Promise.all(Array.from({ length: 8 }, (_unused, index) =>
store.createOidcState(oidcInput(`n${String(index).padStart(42, "0")}`, `v${String(index).padStart(42, "0")}`), base)));
const admitted = await store.createOidcState(
oidcInput("x".repeat(43), "y".repeat(43)),
new Date(base.getTime() + 10 * 60_000),
);
expect(expired.every(({ state }) => !existsSync(digestPath(storageRoot, "oidc", state)))).toBe(true);
expect(existsSync(digestPath(storageRoot, "oidc", admitted.state))).toBe(true);
});
test("bounds OIDC state creation across concurrent Node processes", async () => {
const storageRoot = root();
const creators = await Promise.all([
isolatedOidcCreator(storageRoot, 6),
isolatedOidcCreator(storageRoot, 6),
]);
creators.forEach(({ start }) => start());
const winners = await Promise.all(creators.map(({ result }) => result));
expect(winners.reduce((sum, value) => sum + value, 0)).toBe(8);
const names = readdirSync(join(storageRoot, "oidc"));
expect(names.filter((name) => /^[a-f0-9]{64}\.json$/.test(name))).toHaveLength(8);
});
test("fails closed when an OIDC state already has an atomic filesystem claim", async () => {
const storageRoot = root();
const store = validStore(storageRoot);