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}`;
|
||||
|
||||
Reference in New Issue
Block a user