fix(auth): isolate bounded OIDC cleanup
This commit is contained in:
@@ -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}`;
|
||||
|
||||
@@ -241,6 +241,51 @@ test("cancels and releases a non-success JWKS response body", async () => {
|
||||
expect(body.locked).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects promptly and releases the JWKS reader when stream cancellation never settles", async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream({
|
||||
pull() { /* a non-success response is rejected before any body read */ },
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
return new Promise<void>(() => { /* deliberately never settles */ });
|
||||
},
|
||||
});
|
||||
const subject = protocol({ jwksResponse: () => new Response(body, { status: 503 }) });
|
||||
const completion = callback(subject).then(() => "resolved" as const, () => "rejected" as const);
|
||||
const outcome = await Promise.race([
|
||||
completion,
|
||||
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 100)),
|
||||
]);
|
||||
|
||||
expect(outcome).toBe("rejected");
|
||||
expect(cancelled).toBe(true);
|
||||
expect(body.locked).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves the JWKS body timeout when cancellation never settles", async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream({
|
||||
pull() { return new Promise<void>(() => { /* body read deliberately hangs */ }); },
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
return new Promise<void>(() => { /* deliberately never settles */ });
|
||||
},
|
||||
});
|
||||
const subject = protocol({
|
||||
jwksTimeoutMs: 20,
|
||||
jwksResponse: () => new Response(body),
|
||||
});
|
||||
const completion = callback(subject).then(() => "resolved" as const, () => "rejected" as const);
|
||||
const outcome = await Promise.race([
|
||||
completion,
|
||||
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 100)),
|
||||
]);
|
||||
|
||||
expect(outcome).toBe("rejected");
|
||||
expect(cancelled).toBe(true);
|
||||
expect(body.locked).toBe(false);
|
||||
});
|
||||
|
||||
test("stops streaming a JWKS response as soon as the byte limit is exceeded", async () => {
|
||||
let pulls = 0;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -199,6 +199,39 @@ describe("Windows auth-storage bridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes an explicit bounded directory limit to the Go helper", async () => {
|
||||
const invoke = vi.fn(async () => ({
|
||||
code: 0,
|
||||
stdout: Buffer.from('{"version":1,"ok":true,"entries":[]}\n'),
|
||||
stderr: Buffer.alloc(0),
|
||||
}));
|
||||
const bridge = createWindowsAuthStorageBridge({ thtExecutable: "C:\\tht.exe", invoke });
|
||||
|
||||
await expect(bridge.list(root, "oidc", 192)).resolves.toEqual([]);
|
||||
expect(JSON.parse(invoke.mock.calls[0][0].input.toString("utf8"))).toMatchObject({
|
||||
operation: "list",
|
||||
directory: "oidc",
|
||||
maximumEntries: 192,
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts a bounded ordinary-session page larger than the legacy 256-entry limit", async () => {
|
||||
const entries = Array.from({ length: 300 }, (_unused, index) => ({
|
||||
name: `${index.toString(16).padStart(64, "0")}.json`,
|
||||
modifiedUnixMs: 1_893_456_245_000,
|
||||
}));
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
invoke: async () => ({
|
||||
code: 0,
|
||||
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, entries })}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(bridge.list(root, "sessions", 300)).resolves.toHaveLength(300);
|
||||
});
|
||||
|
||||
test("parses the Go helper's required empty entries array", async () => {
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
|
||||
Reference in New Issue
Block a user