fix(auth): bound OIDC initiation and transport
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
createFileAuthSessionStore,
|
||||
deriveCsrfToken,
|
||||
type AuthSessionStore,
|
||||
type FileAuthSessionStoreOptions,
|
||||
type SessionCreateInput,
|
||||
} from "../src/auth/session-store.js";
|
||||
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
|
||||
@@ -104,11 +106,11 @@ function claimPath(rootPath: string, rawState: string): string {
|
||||
return join(rootPath, "oidc", `${createHash("sha256").update(rawState).digest("hex")}.claim`);
|
||||
}
|
||||
|
||||
function validStore(storageRoot: string): AuthSessionStore {
|
||||
function validStore(storageRoot: string, options: FileAuthSessionStoreOptions = {}): AuthSessionStore {
|
||||
return createFileAuthSessionStore(storageRoot, {
|
||||
currentAuthConfigRevision: () => revision,
|
||||
findLocalUser: async () => validLocalUser,
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
async function create(
|
||||
@@ -180,6 +182,52 @@ async function isolatedOidcConsumer(storageRoot: string, state: string): Promise
|
||||
return { start: () => child.send("consume"), result };
|
||||
}
|
||||
|
||||
async function isolatedOidcCreator(storageRoot: string, attempts: number): Promise<{
|
||||
start(): void;
|
||||
result: Promise<number>;
|
||||
}> {
|
||||
const child = fork(new URL("./fixtures/oidc-state-creator.mts", import.meta.url), [], {
|
||||
cwd: process.cwd(),
|
||||
execArgv: ["--import", "tsx"],
|
||||
env: {
|
||||
...process.env,
|
||||
THT_TEST_SESSION_ROOT: storageRoot,
|
||||
THT_TEST_OIDC_ATTEMPTS: String(attempts),
|
||||
THT_TEST_OIDC_NOW: base.toISOString(),
|
||||
THT_TEST_OIDC_CAPACITY: "8",
|
||||
},
|
||||
silent: true,
|
||||
});
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
child.once("message", (message) => {
|
||||
if (message === "ready") resolve();
|
||||
else reject(new Error("OIDC creator did not become ready"));
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code !== null && code !== 0) reject(new Error("OIDC creator exited before ready"));
|
||||
});
|
||||
});
|
||||
const result = new Promise<number>((resolve, reject) => {
|
||||
let outcome: { created: unknown; failed?: unknown; error?: unknown } | undefined;
|
||||
child.on("message", (message) => {
|
||||
if (message && typeof message === "object" && "created" in message) {
|
||||
outcome = message as { created: unknown; failed?: unknown; error?: unknown };
|
||||
}
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0 || outcome?.failed === true || typeof outcome?.created !== "number") {
|
||||
reject(new Error(`OIDC creator failed: ${String(outcome?.error)}`));
|
||||
} else {
|
||||
resolve(outcome.created);
|
||||
}
|
||||
});
|
||||
});
|
||||
await ready;
|
||||
return { start: () => child.send("create"), result };
|
||||
}
|
||||
|
||||
describe("file-backed auth session store", () => {
|
||||
test("fails closed and revokes a session when constructed without validity dependencies", async () => {
|
||||
const storageRoot = root();
|
||||
@@ -301,6 +349,49 @@ describe("file-backed auth session store", () => {
|
||||
.resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects an OIDC-state flood without deleting any valid live state", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot, { oidcStateCapacity: 8 });
|
||||
const created = await Promise.all(Array.from({ length: 8 }, (_unused, index) =>
|
||||
store.createOidcState(oidcInput(`n${String(index).padStart(42, "0")}`, `v${String(index).padStart(42, "0")}`), base)));
|
||||
|
||||
await expect(store.createOidcState(oidcInput("x".repeat(43), "y".repeat(43)), base))
|
||||
.rejects.toThrow("auth_oidc_state_capacity");
|
||||
await expect(store.consumeOidcState(created[0].state, new Date(base.getTime() + 60_000)))
|
||||
.resolves.toMatchObject({ nonce: "n" + "0".repeat(42) });
|
||||
expect(created.slice(1).every(({ state }) => existsSync(digestPath(storageRoot, "oidc", state)))).toBe(true);
|
||||
});
|
||||
|
||||
test("prunes expired OIDC states before admitting a new transaction at capacity", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot, { oidcStateCapacity: 8 });
|
||||
const expired = await Promise.all(Array.from({ length: 8 }, (_unused, index) =>
|
||||
store.createOidcState(oidcInput(`n${String(index).padStart(42, "0")}`, `v${String(index).padStart(42, "0")}`), base)));
|
||||
|
||||
const admitted = await store.createOidcState(
|
||||
oidcInput("x".repeat(43), "y".repeat(43)),
|
||||
new Date(base.getTime() + 10 * 60_000),
|
||||
);
|
||||
|
||||
expect(expired.every(({ state }) => !existsSync(digestPath(storageRoot, "oidc", state)))).toBe(true);
|
||||
expect(existsSync(digestPath(storageRoot, "oidc", admitted.state))).toBe(true);
|
||||
});
|
||||
|
||||
test("bounds OIDC state creation across concurrent Node processes", async () => {
|
||||
const storageRoot = root();
|
||||
const creators = await Promise.all([
|
||||
isolatedOidcCreator(storageRoot, 6),
|
||||
isolatedOidcCreator(storageRoot, 6),
|
||||
]);
|
||||
|
||||
creators.forEach(({ start }) => start());
|
||||
const winners = await Promise.all(creators.map(({ result }) => result));
|
||||
|
||||
expect(winners.reduce((sum, value) => sum + value, 0)).toBe(8);
|
||||
const names = readdirSync(join(storageRoot, "oidc"));
|
||||
expect(names.filter((name) => /^[a-f0-9]{64}\.json$/.test(name))).toHaveLength(8);
|
||||
});
|
||||
|
||||
test("fails closed when an OIDC state already has an atomic filesystem claim", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
|
||||
Reference in New Issue
Block a user