fix(auth): paginate session maintenance safely

This commit is contained in:
2026-08-17 09:05:20 +02:00
parent 3e7bb11313
commit c86f01e886
12 changed files with 1021 additions and 79 deletions
+220 -6
View File
@@ -13,6 +13,7 @@ import {
renameSync,
rmSync,
symlinkSync,
utimesSync,
unlinkSync,
writeFileSync,
} from "node:fs";
@@ -84,9 +85,14 @@ function oidcInput(nonce: string, codeVerifier: string) {
authConfigRevision: revision,
issuer: "https://issuer.example.test",
browserTransactionDigest: "b".repeat(64),
browserTransactionTransport: "https" as const,
};
}
function sessionFilename(index: number): string {
return `${index.toString(16).padStart(64, "0")}.json`;
}
afterEach(() => {
fsHooks.afterRead = undefined;
fsHooks.afterWrite = undefined;
@@ -334,6 +340,104 @@ describe("file-backed auth session store", () => {
expect(existsSync(digestPath(storageRoot, "oidc", oidc.state))).toBe(false);
});
test.skipIf(process.platform === "win32")("allows startup maintenance past 512 live records and keeps OIDC available", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const liveSeed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
const liveContents = readFileSync(digestPath(storageRoot, "sessions", liveSeed.token));
unlinkSync(digestPath(storageRoot, "sessions", liveSeed.token));
const expiredSeed = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
const expiredContents = readFileSync(digestPath(storageRoot, "sessions", expiredSeed.token));
unlinkSync(digestPath(storageRoot, "sessions", expiredSeed.token));
for (let index = 0; index < 512; index += 1) {
writeFileSync(join(storageRoot, "sessions", sessionFilename(index)), liveContents, { mode: 0o600 });
}
const expiredName = sessionFilename(512);
writeFileSync(join(storageRoot, "sessions", expiredName), expiredContents, { mode: 0o600 });
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(0);
expect(existsSync(join(storageRoot, "sessions", expiredName))).toBe(true);
expect(existsSync(join(storageRoot, "sessions", sessionFilename(0)))).toBe(true);
await expect(store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base))
.resolves.toMatchObject({ record: { nonce: "n".repeat(43) } });
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(1);
expect(existsSync(join(storageRoot, "sessions", expiredName))).toBe(false);
expect(existsSync(join(storageRoot, "sessions", sessionFilename(0)))).toBe(true);
});
test.skipIf(process.platform === "win32")("serializes concurrent continuation passes without stranding a later page", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const liveSeed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
const liveContents = readFileSync(digestPath(storageRoot, "sessions", liveSeed.token));
unlinkSync(digestPath(storageRoot, "sessions", liveSeed.token));
const expiredSeed = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
const expiredContents = readFileSync(digestPath(storageRoot, "sessions", expiredSeed.token));
unlinkSync(digestPath(storageRoot, "sessions", expiredSeed.token));
for (let index = 0; index < 512; index += 1) {
writeFileSync(join(storageRoot, "sessions", sessionFilename(index)), liveContents, { mode: 0o600 });
}
const expiredName = sessionFilename(512);
writeFileSync(join(storageRoot, "sessions", expiredName), expiredContents, { mode: 0o600 });
await expect(Promise.all([
store.prune(new Date(base.getTime() + 2 * 60_000)),
store.prune(new Date(base.getTime() + 2 * 60_000)),
])).resolves.toEqual([0, 1]);
expect(existsSync(join(storageRoot, "sessions", expiredName))).toBe(false);
expect(existsSync(join(storageRoot, "sessions", sessionFilename(0)))).toBe(true);
});
test.skipIf(process.platform === "win32")("fails closed on an unsafe ordinary record beyond the first continuation page", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const seed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
const contents = readFileSync(digestPath(storageRoot, "sessions", seed.token));
unlinkSync(digestPath(storageRoot, "sessions", seed.token));
for (let index = 0; index < 512; index += 1) {
writeFileSync(join(storageRoot, "sessions", sessionFilename(index)), contents, { mode: 0o600 });
}
symlinkSync(join(storageRoot, "missing-target"), join(storageRoot, "sessions", sessionFilename(512)));
await expect(store.prune(new Date(base.getTime() + 2 * 60_000)))
.rejects.toThrow("auth_session_store_invalid");
});
test.skipIf(process.platform === "win32")("fails closed if the ordinary-session directory changes during a page scan", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const seed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
const contents = readFileSync(digestPath(storageRoot, "sessions", seed.token));
unlinkSync(digestPath(storageRoot, "sessions", seed.token));
const filename = sessionFilename(0);
const sessionsDirectory = join(storageRoot, "sessions");
writeFileSync(join(sessionsDirectory, filename), contents, { mode: 0o600 });
fsHooks.beforeLstat = (path) => {
if (path !== join(sessionsDirectory, filename)) return false;
const changed = new Date(base.getTime() + 60 * 60_000);
utimesSync(sessionsDirectory, changed, changed);
return true;
};
await expect(store.prune(new Date(base.getTime() + 2 * 60_000)))
.rejects.toThrow("auth_session_store_invalid");
});
test("persists the OIDC transaction transport as consumed state metadata", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({
...oidcInput("n".repeat(43), "v".repeat(43)),
browserTransactionTransport: "loopback_http",
});
await expect(store.consumeOidcState(created.state)).resolves.toMatchObject({
browserTransactionTransport: "loopback_http",
});
});
test("creates bounded OIDC state records that expire and are single-use", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
@@ -347,6 +451,7 @@ describe("file-backed auth session store", () => {
nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/",
authConfigRevision: revision, issuer: "https://issuer.example.test",
browserTransactionDigest: "b".repeat(64),
browserTransactionTransport: "https",
});
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
@@ -740,6 +845,19 @@ describe("file-backed auth session store", () => {
.filter((entry) => entry.startsWith(`${directory}/`))
.map((entry) => ({ name: entry.slice(directory.length + 1), modifiedUnixMs: base.getTime() }));
},
listPage: async (_root: string, directory: string, after: string | undefined, maximumEntries: number) => {
calls.push("list");
if (directory !== "sessions") throw new Error("only ordinary sessions use continuation pages");
const names = [...records.keys()]
.filter((entry) => entry.startsWith("sessions/"))
.map((entry) => entry.slice("sessions/".length))
.filter((name) => after === undefined || name > after)
.sort();
return {
entries: names.slice(0, maximumEntries).map((name) => ({ name, modifiedUnixMs: base.getTime() })),
more: names.length > maximumEntries,
};
},
claimConsume: async (_root: string, filename: string) => {
calls.push("claim-consume");
const entry = key("oidc", filename);
@@ -778,6 +896,75 @@ describe("file-backed auth session store", () => {
}
});
test("continues Windows ordinary-session maintenance with a strict 512-record page cursor", async () => {
const records = new Map<string, Buffer>();
const pages: Array<{ after: string | undefined; maximumEntries: number }> = [];
const key = (directory: string, filename: string) => `${directory}/${filename}`;
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) => {
const value = records.get(key(directory, filename));
return value === undefined ? undefined : Buffer.from(value);
},
remove: async (_root: string, directory: string, filename: string) => records.delete(key(directory, filename)),
list: async (_root: string, directory: string) => {
if (directory === "sessions") throw new Error("ordinary maintenance must use a continuation page");
return [];
},
listPage: async (_root: string, directory: string, after: string | undefined, maximumEntries: number) => {
if (directory !== "sessions") throw new Error("only ordinary sessions use continuation pages");
pages.push({ after, maximumEntries });
const names = [...records.keys()]
.filter((entry) => entry.startsWith("sessions/"))
.map((entry) => entry.slice("sessions/".length))
.filter((name) => after === undefined || name > after)
.sort();
return {
entries: names.slice(0, maximumEntries).map((name) => ({ name, modifiedUnixMs: base.getTime() })),
more: names.length > maximumEntries,
};
},
};
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", {
currentAuthConfigRevision: () => revision,
findLocalUser: async () => validLocalUser,
}, { windowsStorageBridge: bridge as never });
await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
const liveContents = [...records.values()][0];
records.clear();
const expiredSeed = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
const expiredContents = [...records.values()][0];
records.clear();
if (!liveContents || !expiredContents) throw new Error("seed records missing");
for (let index = 0; index < 512; index += 1) {
records.set(key("sessions", sessionFilename(index)), Buffer.from(liveContents));
}
const expiredName = sessionFilename(512);
records.set(key("sessions", expiredName), Buffer.from(expiredContents));
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(0);
expect(records.has(key("sessions", expiredName))).toBe(true);
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(1);
expect(records.has(key("sessions", expiredName))).toBe(false);
expect(records.has(key("sessions", sessionFilename(0)))).toBe(true);
expect(pages).toEqual([
{ after: undefined, maximumEntries: 512 },
{ after: sessionFilename(511), maximumEntries: 512 },
]);
} finally {
Object.defineProperty(process, "platform", originalPlatform);
}
});
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) {
@@ -863,6 +1050,8 @@ describe("file-backed auth session store", () => {
directory: "sessions" | "oidc";
filename?: string;
contentBase64?: string;
continuation?: boolean;
afterName?: string;
};
const entry = request.filename === undefined ? undefined : key(request.directory, request.filename);
switch (request.operation) {
@@ -877,6 +1066,17 @@ describe("file-backed auth session store", () => {
case "remove":
return response({ removed: entry !== undefined && records.delete(entry) });
case "list":
if (request.continuation === true) {
const names = [...records.keys()]
.filter((value) => value.startsWith(`${request.directory}/`))
.map((value) => value.slice(request.directory.length + 1))
.filter((name) => request.afterName === undefined || name > request.afterName)
.sort();
return response({
entries: names.slice(0, 512).map((name) => ({ name, modifiedUnixMs: base.getTime() })),
more: names.length > 512,
});
}
return response({
// Raw lower-camel entry objects, exactly as authstorage's Go response emits them.
entries: [...records.keys()]
@@ -909,11 +1109,19 @@ describe("file-backed auth session store", () => {
test("prunes empty Windows directories through the required Go entries array", async () => {
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
invoke: async () => ({
code: 0,
stdout: Buffer.from('{"version":1,"ok":true,"entries":[]}\n'),
stderr: Buffer.alloc(0),
}),
invoke: async ({ input }) => {
const request = JSON.parse(input.toString("utf8")) as { continuation?: boolean };
return {
code: 0,
stdout: Buffer.from(`${JSON.stringify({
version: 1,
ok: true,
entries: [],
...(request.continuation === true ? { more: false } : {}),
})}\n`),
stderr: Buffer.alloc(0),
};
},
});
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatform) throw new Error("platform descriptor unavailable");
@@ -936,7 +1144,12 @@ describe("file-backed auth session store", () => {
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
invoke: async ({ input }) => {
const request = JSON.parse(input.toString("utf8")) as { operation: string; directory: string; filename?: string };
const request = JSON.parse(input.toString("utf8")) as {
operation: string;
directory: string;
filename?: string;
continuation?: boolean;
};
const response = (value: Record<string, unknown>) => ({
code: 0,
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...value })}\n`),
@@ -947,6 +1160,7 @@ describe("file-backed auth session store", () => {
entries: request.directory === "oidc"
? [{ name: claim, modifiedUnixMs: base.getTime() }]
: [],
...(request.continuation === true ? { more: false } : {}),
});
}
if (request.operation === "remove" && request.directory === "oidc" && request.filename === claim) {