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
+156 -16
View File
@@ -12,6 +12,7 @@ const state = "s".repeat(43);
const nonce = "n".repeat(43);
const verifier = "v".repeat(43);
const transactionCookieName = "__Host-thothii_oidc_tx";
const loopbackTransactionCookieName = "thothii_oidc_tx";
const createdApps: Array<ReturnType<typeof Fastify>> = [];
function setCookieHeaders(response: { headers: Record<string, string | string[] | undefined> }): string[] {
@@ -19,15 +20,42 @@ function setCookieHeaders(response: { headers: Record<string, string | string[]
return header === undefined ? [] : Array.isArray(header) ? header : [header];
}
function transactionCookie(response: { headers: Record<string, string | string[] | undefined> }): string | undefined {
function transactionCookie(
response: { headers: Record<string, string | string[] | undefined> },
name = transactionCookieName,
): string | undefined {
return setCookieHeaders(response)
.find((header) => header.startsWith(`${transactionCookieName}=`))
.find((header) => header.startsWith(`${name}=`) && !header.includes("Max-Age=0"))
?.split(";", 1)[0];
}
function expectTransactionCleared(response: { headers: Record<string, string | string[] | undefined> }): void {
expect(setCookieHeaders(response).some((header) =>
header.startsWith(`${transactionCookieName}=`) && header.includes("Max-Age=0"))).toBe(true);
function expectTransactionCleared(
response: { headers: Record<string, string | string[] | undefined> },
name = transactionCookieName,
secure = true,
): void {
const header = setCookieHeaders(response).find((candidate) =>
candidate.startsWith(`${name}=`) && candidate.includes("Max-Age=0"));
expect(header).toBeDefined();
expect(header).toContain("HttpOnly");
expect(header).toContain("SameSite=Lax");
expect(header).toContain("Path=/");
expect(header).not.toContain("Domain=");
if (secure) expect(header).toContain("Secure");
else expect(header).not.toContain("Secure");
}
function expectBothTransactionVariantsCleared(
response: { headers: Record<string, string | string[] | undefined> },
activeName: string,
activeSecure: boolean,
): void {
expectTransactionCleared(response, activeName, activeSecure);
expectTransactionCleared(
response,
activeName === transactionCookieName ? loopbackTransactionCookieName : transactionCookieName,
activeName !== transactionCookieName,
);
}
function config(overrides: Partial<LoadedAuthConfig["value"]> = {}): LoadedAuthConfig {
@@ -58,6 +86,7 @@ function stateRecord(extra: Partial<OidcStateRecord> = {}): OidcStateRecord {
version: 1, nonce, codeVerifier: verifier, returnTo: "/",
authConfigRevision: revision, issuer,
browserTransactionDigest: "b".repeat(64),
browserTransactionTransport: "https",
createdAt: "2030-01-01T00:00:00.000Z", expiresAt: "2030-01-01T00:10:00.000Z",
...extra,
} as OidcStateRecord;
@@ -106,6 +135,8 @@ function fixture(options: {
});
(record as OidcStateRecord & { browserTransactionDigest: string }).browserTransactionDigest =
input.browserTransactionDigest as string;
(record as OidcStateRecord & { browserTransactionTransport?: string }).browserTransactionTransport =
input.browserTransactionTransport as string | undefined ?? "https";
if (options.stateReturnTo) (record as { returnTo: string }).returnTo = options.stateReturnTo;
storedState = record;
return { state, record: storedState };
@@ -149,7 +180,10 @@ afterEach(async () => {
async function beginOidcLogin(subject: ReturnType<typeof fixture>) {
const response = await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
return { response, cookie: transactionCookie(response) };
return {
response,
cookie: transactionCookie(response) ?? transactionCookie(response, loopbackTransactionCookieName),
};
}
async function finishOidcLogin(
@@ -208,12 +242,16 @@ test("creates digest-only bound state, maps exact groups, creates a cookie sessi
expect(subject.stateInputs[0]).toMatchObject({
returnTo: "/", authConfigRevision: revision, issuer,
browserTransactionDigest: expect.stringMatching(/^[a-f0-9]{64}$/),
browserTransactionTransport: "https",
});
expect(cookie).toMatch(new RegExp(`^${transactionCookieName}=[A-Za-z0-9_-]{43}$`));
expect(setCookieHeaders(start).join("\n")).toContain("HttpOnly");
expect(setCookieHeaders(start).join("\n")).toContain("SameSite=Lax");
expect(setCookieHeaders(start).join("\n")).toContain("Secure");
expect(setCookieHeaders(start).join("\n")).toContain("Path=/");
const issued = setCookieHeaders(start)
.find((header) => header.startsWith(`${transactionCookieName}=`) && !header.includes("Max-Age=0"));
expect(issued).toContain("HttpOnly");
expect(issued).toContain("SameSite=Lax");
expect(issued).toContain("Secure");
expect(issued).toContain("Path=/");
expect(issued).not.toContain("Domain=");
expect(JSON.stringify(subject.stateInputs)).not.toContain(cookie?.split("=", 2)[1] ?? "missing-cookie");
const callback = await subject.app.inject({
@@ -226,7 +264,7 @@ test("creates digest-only bound state, maps exact groups, creates a cookie sessi
expect(setCookieHeaders(callback).join("\n")).toContain("HttpOnly");
expect(setCookieHeaders(callback).join("\n")).toContain("SameSite=Lax");
expect(setCookieHeaders(callback).join("\n")).toContain("Secure");
expectTransactionCleared(callback);
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
expect(subject.callbacks[0]?.href).toBe(`https://thothii.example.test/api/auth/oidc/callback?code=good&state=${state}`);
expect(subject.creates).toHaveLength(1);
expect(subject.creates[0]).toMatchObject({
@@ -242,12 +280,103 @@ test("creates digest-only bound state, maps exact groups, creates a cookie sessi
expect(JSON.stringify(subject.creates)).not.toContain("refresh-token-must-not-leak");
});
test.each([
"http://127.42.0.1:8787",
"http://[::1]:8787",
])("uses the non-prefixed, non-Secure transaction cookie for literal loopback OIDC %s", async (publicUrl) => {
const subject = fixture({ loaded: config({ publicUrl }) });
const { response: start, cookie } = await beginOidcLogin(subject);
expect(start.statusCode).toBe(302);
expect(subject.stateInputs[0]).toMatchObject({ browserTransactionTransport: "loopback_http" });
expect(cookie).toMatch(new RegExp(`^${loopbackTransactionCookieName}=[A-Za-z0-9_-]{43}$`));
const issued = setCookieHeaders(start).find((header) => header.startsWith(`${loopbackTransactionCookieName}=`));
expect(issued).toContain("HttpOnly");
expect(issued).toContain("SameSite=Lax");
expect(issued).toContain("Path=/");
expect(issued).not.toContain("Secure");
expect(issued).not.toContain("Domain=");
const callback = await finishOidcLogin(subject, cookie);
expect(callback.statusCode).toBe(302);
expect(subject.callbacks[0]?.href).toBe(`${publicUrl}/api/auth/oidc/callback?code=good&state=${state}`);
expectBothTransactionVariantsCleared(callback, loopbackTransactionCookieName, false);
});
test("uses the consumed state transport to reject cross-mode and shadowed transaction cookies", async () => {
const subject = fixture({ loaded: config({ publicUrl: "http://127.0.0.1:8787" }) });
const { cookie } = await beginOidcLogin(subject);
expect(cookie).toBeDefined();
const failed = await finishOidcLogin(subject, [
cookie,
`${transactionCookieName}=${"x".repeat(43)}`,
].join("; "));
expect(failed.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
});
test("rejects a transaction presented only under the wrong transport cookie name", async () => {
const subject = fixture();
const { cookie } = await beginOidcLogin(subject);
const wrongTransportCookie = cookie?.replace(transactionCookieName, loopbackTransactionCookieName);
const failed = await finishOidcLogin(subject, wrongTransportCookie);
expect(failed.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
});
test("rejects duplicate same-mode transaction cookies instead of trusting a parser-selected value", async () => {
const subject = fixture();
const { cookie } = await beginOidcLogin(subject);
expect(cookie).toBeDefined();
const failed = await finishOidcLogin(subject, [
cookie,
`${transactionCookieName}=${"x".repeat(43)}`,
].join("; "));
expect(failed.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
expect(subject.stateWasConsumed()).toBe(true);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
});
test("pins the callback transaction transport to its captured configuration snapshot", async () => {
const subject = fixture({ loaded: config({ publicUrl: "http://127.42.0.1:8787" }) });
const { cookie } = await beginOidcLogin(subject);
subject.setConfig(config({ publicUrl: "https://thothii.example.test" }));
const failed = await finishOidcLogin(subject, cookie);
expect(failed.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
expect(subject.stateWasConsumed()).toBe(true);
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
});
test("clears the state-pinned loopback transaction variant after a terminal callback failure", async () => {
const subject = fixture({
loaded: config({ publicUrl: "http://127.0.0.1:8787" }),
callbackFailure: true,
});
const { cookie } = await beginOidcLogin(subject);
const failed = await finishOidcLogin(subject, cookie);
expect(failed.statusCode).toBe(401);
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
});
test("consumes state on callback failure and refuses replay", async () => {
const subject = fixture({ callbackFailure: true });
const { cookie } = await beginOidcLogin(subject);
const failed = await finishOidcLogin(subject, cookie);
expect(failed.statusCode).toBe(401);
expectTransactionCleared(failed);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
const replay = await finishOidcLogin(subject, cookie);
expect(replay.statusCode).toBe(401);
expect(subject.callbacks).toHaveLength(1);
@@ -260,6 +389,7 @@ test("consumes state when the protocol becomes unavailable before callback", asy
const failed = await finishOidcLogin(subject, cookie);
expect(failed.statusCode).toBe(401);
expect(subject.stateWasConsumed()).toBe(true);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
});
test("rejects a consumed state with a non-root return target", async () => {
@@ -269,6 +399,7 @@ test("rejects a consumed state with a non-root return target", async () => {
expect(callback.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
expect(subject.creates).toEqual([]);
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
});
test("rejects an OIDC state when its configuration revision changes before callback", async () => {
@@ -278,6 +409,7 @@ test("rejects an OIDC state when its configuration revision changes before callb
const callback = await finishOidcLogin(subject, cookie);
expect(callback.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
});
test("rejects an OIDC state when its issuer changes before callback", async () => {
@@ -310,7 +442,7 @@ test("rejects a callback from a different browser and clears the transaction coo
const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`);
expect(failed.statusCode).toBe(401);
expectTransactionCleared(failed);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
expect(subject.stateWasConsumed()).toBe(true);
expect(subject.creates).toEqual([]);
@@ -328,7 +460,7 @@ test.each([
const { cookie } = await beginOidcLogin(subject);
const failed = await finishOidcLogin(subject, cookie, query);
expect(failed.statusCode).toBe(401);
expectTransactionCleared(failed);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
expect(subject.stateWasConsumed()).toBe(true);
expect(subject.callbacks).toEqual([]);
@@ -345,7 +477,7 @@ test("boundedly burns a canonical state from an oversized callback URL", async (
const failed = await finishOidcLogin(subject, cookie, oversized);
expect(failed.statusCode).toBe(401);
expectTransactionCleared(failed);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
expect(subject.stateWasConsumed()).toBe(true);
expect(subject.callbacks).toEqual([]);
expect((await finishOidcLogin(subject, cookie)).statusCode).toBe(401);
@@ -358,11 +490,19 @@ test("rejects an empty direct groups claim without creating a session cookie", a
const { cookie } = await beginOidcLogin(subject);
const callback = await finishOidcLogin(subject, cookie);
expect(callback.statusCode).toBe(401);
expectTransactionCleared(callback);
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
expect(subject.creates).toEqual([]);
expect(setCookieHeaders(callback).join("\n")).not.toContain("opaque-session-token");
});
test("clears both transaction variants when the callback state cannot be consumed", async () => {
const subject = fixture();
const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`);
expect(failed.statusCode).toBe(401);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
});
test("uses one captured instant for token TTL derivation and session creation", async () => {
const tokenExpiresAt = new Date(Date.now() + 120_000);
const subject = fixture({ identity: {
+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) {
+1
View File
@@ -22,6 +22,7 @@ process.once("message", async (message) => {
authConfigRevision: "a".repeat(64),
issuer: "https://issuer.example.test",
browserTransactionDigest: "b".repeat(64),
browserTransactionTransport: "https",
}, now);
created += 1;
} catch (error) {
+52
View File
@@ -215,6 +215,58 @@ describe("Windows auth-storage bridge", () => {
});
});
test("uses a strict, bounded continuation page for ordinary Windows session maintenance", async () => {
const invoke = vi.fn(async () => ({
code: 0,
stdout: Buffer.from(`{"version":1,"ok":true,"entries":[{"name":"${filename}","modifiedUnixMs":1}],"more":false}\n`),
stderr: Buffer.alloc(0),
}));
const bridge = createWindowsAuthStorageBridge({ thtExecutable: "C:\\tht.exe", invoke }) as unknown as {
listPage(root: string, directory: "sessions", after: string | undefined, maximumEntries: number): Promise<{
entries: Array<{ name: string; modifiedUnixMs: number }>;
more: boolean;
}>;
};
await expect(bridge.listPage(root, "sessions", "0".repeat(64) + ".json", 512)).resolves.toEqual({
entries: [{ name: filename, modifiedUnixMs: 1 }],
more: false,
});
expect(JSON.parse(invoke.mock.calls[0][0].input.toString("utf8"))).toMatchObject({
operation: "list",
directory: "sessions",
maximumEntries: 512,
continuation: true,
afterName: "0".repeat(64) + ".json",
});
});
test("rejects ambiguous ordinary-session continuation responses", async () => {
const after = "f".repeat(64) + ".json";
const low = "a".repeat(64) + ".json";
const high = "b".repeat(64) + ".json";
const cases = [
{ label: "missing more marker", body: { entries: [{ name: filename, modifiedUnixMs: 1 }] } },
{ label: "more without a full page", body: { entries: [{ name: filename, modifiedUnixMs: 1 }], more: true } },
{ label: "non-progressing name", body: { entries: [{ name: low, modifiedUnixMs: 1 }], more: false } },
{ label: "duplicate names", body: { entries: [{ name: high, modifiedUnixMs: 1 }, { name: high, modifiedUnixMs: 2 }], more: false } },
];
for (const { body } of cases) {
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
invoke: async () => ({
code: 0,
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...body })}\n`),
stderr: Buffer.alloc(0),
}),
}) as unknown as {
listPage(root: string, directory: "sessions", afterName: string | undefined, maximumEntries: number): Promise<unknown>;
};
await expect(bridge.listPage(root, "sessions", after, 512)).rejects.toThrow("auth_session_store_invalid");
}
});
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`,