fix(auth): paginate session maintenance safely
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user