fix(auth): isolate bounded OIDC cleanup

This commit is contained in:
2026-08-17 07:46:43 +02:00
parent bdabecbb63
commit 3e7bb11313
8 changed files with 456 additions and 177 deletions
+144
View File
@@ -25,6 +25,7 @@ const fsHooks = vi.hoisted(() => ({
afterWrite: undefined as undefined | (() => void),
beforeLstat: undefined as undefined | ((path: string) => boolean),
afterLstat: undefined as undefined | ((path: string) => boolean),
beforeReaddir: undefined as undefined | ((path: string) => void),
transformLstat: undefined as undefined | ((path: string, info: import("node:fs").Stats) => import("node:fs").Stats),
}));
@@ -55,6 +56,10 @@ vi.mock("node:fs", async (importOriginal) => {
if (callback?.(String(args[0]))) fsHooks.afterLstat = undefined;
return result;
},
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => {
fsHooks.beforeReaddir?.(String(args[0]));
return actual.readdirSync(...args);
},
};
});
import {
@@ -87,6 +92,7 @@ afterEach(() => {
fsHooks.afterWrite = undefined;
fsHooks.beforeLstat = undefined;
fsHooks.afterLstat = undefined;
fsHooks.beforeReaddir = undefined;
fsHooks.transformLstat = undefined;
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
@@ -377,6 +383,75 @@ describe("file-backed auth session store", () => {
expect(existsSync(digestPath(storageRoot, "oidc", admitted.state))).toBe(true);
});
test.skipIf(process.platform === "win32")("starts OIDC without scanning more than 256 ordinary session records", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const liveSession = await create(store);
for (let index = 0; index < 300; index += 1) {
writeFileSync(join(storageRoot, "sessions", `${index.toString(16).padStart(64, "0")}.json`), "{}", {
encoding: "utf8",
mode: 0o600,
});
}
const oidc = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
expect(oidc.state).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(existsSync(digestPath(storageRoot, "sessions", liveSession.token))).toBe(true);
});
test.skipIf(process.platform === "win32")("uses bounded directory reads for ordinary and OIDC pruning", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const expiredSession = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
const expiredOidc = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
fsHooks.beforeReaddir = (path) => {
if (path === join(storageRoot, "sessions") || path === join(storageRoot, "oidc")) {
throw new Error("unbounded directory read");
}
};
await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(2);
expect(existsSync(digestPath(storageRoot, "sessions", expiredSession.token))).toBe(false);
expect(existsSync(digestPath(storageRoot, "oidc", expiredOidc.state))).toBe(false);
});
test.skipIf(process.platform === "win32")("rejects an overfull OIDC directory without deleting a live state", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const live = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
for (let index = 0; index < 193; index += 1) {
writeFileSync(join(storageRoot, "oidc", `${(index + 1_000).toString(16).padStart(64, "0")}.json`), "{}", {
encoding: "utf8",
mode: 0o600,
});
}
await expect(store.createOidcState(oidcInput("x".repeat(43), "y".repeat(43)), base))
.rejects.toThrow("auth_session_store_invalid");
expect(existsSync(digestPath(storageRoot, "oidc", live.state))).toBe(true);
});
test("OIDC-only cleanup removes expired state while preserving a live transaction", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const expired = await store.createOidcState(oidcInput("e".repeat(43), "f".repeat(43)), base);
const live = await store.createOidcState(
oidcInput("l".repeat(43), "m".repeat(43)),
new Date(base.getTime() + 60_000),
);
const admitted = await store.createOidcState(
oidcInput("n".repeat(43), "v".repeat(43)),
new Date(base.getTime() + 10 * 60_000),
);
expect(existsSync(digestPath(storageRoot, "oidc", expired.state))).toBe(false);
expect(existsSync(digestPath(storageRoot, "oidc", admitted.state))).toBe(true);
await expect(store.consumeOidcState(live.state, new Date(base.getTime() + 10 * 60_000)))
.resolves.toMatchObject({ nonce: "l".repeat(43) });
});
test("bounds OIDC state creation across concurrent Node processes", async () => {
const storageRoot = root();
const creators = await Promise.all([
@@ -703,6 +778,75 @@ describe("file-backed auth session store", () => {
}
});
test("starts OIDC on Windows without listing more than 256 ordinary session records", async () => {
const records = new Map<string, Buffer>();
for (let index = 0; index < 300; index += 1) {
records.set(`sessions/${index.toString(16).padStart(64, "0")}.json`, Buffer.from("{}"));
}
const key = (directory: string, filename: string) => `${directory}/${filename}`;
const listed: string[] = [];
const bridge = {
create: async (_root: string, directory: string, filename: string, contents: Buffer) => {
const entry = key(directory, filename);
if (records.has(entry)) return false;
records.set(entry, Buffer.from(contents));
return true;
},
read: async (_root: string, directory: string, filename: string) => records.get(key(directory, filename)),
remove: async (_root: string, directory: string, filename: string) => records.delete(key(directory, filename)),
list: async (_root: string, directory: string) => {
listed.push(directory);
if (directory === "sessions") throw new Error("ordinary sessions must not be listed during OIDC start");
return [...records.keys()]
.filter((entry) => entry.startsWith("oidc/"))
.map((entry) => ({ name: entry.slice("oidc/".length), modifiedUnixMs: base.getTime() }));
},
};
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatform) throw new Error("platform descriptor unavailable");
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
try {
const store = createFileAuthSessionStore("C:\\ProgramData\\ThothII\\auth", undefined, {
windowsStorageBridge: bridge as never,
});
await expect(store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base))
.resolves.toMatchObject({ record: { nonce: "n".repeat(43) } });
expect(listed).toEqual(["oidc", "oidc"]);
expect([...records.keys()].filter((entry) => entry.startsWith("sessions/"))).toHaveLength(300);
} finally {
Object.defineProperty(process, "platform", originalPlatform);
}
});
test("rejects an overfull Windows OIDC directory before reading or deleting live entries", async () => {
const entries = Array.from({ length: 193 }, (_unused, index) => ({
name: `${index.toString(16).padStart(64, "0")}.json`,
modifiedUnixMs: base.getTime(),
}));
const read = vi.fn(async () => undefined);
const remove = vi.fn(async () => false);
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatform) throw new Error("platform descriptor unavailable");
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
try {
const store = createFileAuthSessionStore("C:\\ProgramData\\ThothII\\auth", undefined, {
windowsStorageBridge: {
list: async () => entries,
read,
remove,
} as never,
});
await expect(store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base))
.rejects.toThrow("auth_session_store_invalid");
expect(read).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
} finally {
Object.defineProperty(process, "platform", originalPlatform);
}
});
test("prunes nonempty Windows directories through the Go lower-camel list DTO", async () => {
const records = new Map<string, Buffer>();
const key = (directory: string, entry: string) => `${directory}/${entry}`;