fix(auth): harden persistent sessions

This commit is contained in:
2026-08-16 21:03:07 +02:00
parent ab61c1ad6d
commit 6bf8218fea
3 changed files with 537 additions and 40 deletions
+312 -11
View File
@@ -1,20 +1,58 @@
import { createHash } from "node:crypto";
import { fork } from "node:child_process";
import {
chmodSync,
existsSync,
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
const fsHooks = vi.hoisted(() => ({
afterRead: undefined as undefined | (() => void),
afterWrite: undefined as undefined | (() => void),
afterLstat: undefined as undefined | ((path: string) => boolean),
transformLstat: undefined as undefined | ((path: string, info: import("node:fs").Stats) => import("node:fs").Stats),
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
readSync: (...args: Parameters<typeof actual.readSync>) => {
const result = actual.readSync(...args);
const callback = fsHooks.afterRead;
fsHooks.afterRead = undefined;
callback?.();
return result;
},
writeSync: (...args: Parameters<typeof actual.writeSync>) => {
const result = actual.writeSync(...args);
const callback = fsHooks.afterWrite;
fsHooks.afterWrite = undefined;
callback?.();
return result;
},
lstatSync: (...args: Parameters<typeof actual.lstatSync>) => {
const original = actual.lstatSync(...args);
const result = fsHooks.transformLstat?.(String(args[0]), original) ?? original;
const callback = fsHooks.afterLstat;
if (callback?.(String(args[0]))) fsHooks.afterLstat = undefined;
return result;
},
};
});
import {
createFileAuthSessionStore,
deriveCsrfToken,
@@ -25,8 +63,13 @@ import {
const roots: string[] = [];
const base = new Date("2030-01-02T03:04:05.000Z");
const revision = "a".repeat(64);
const validLocalUser = { enabled: true, authRevision: 7, roles: ["admin"] as const };
afterEach(() => {
fsHooks.afterRead = undefined;
fsHooks.afterWrite = undefined;
fsHooks.afterLstat = undefined;
fsHooks.transformLstat = undefined;
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
@@ -41,6 +84,17 @@ function digestPath(rootPath: string, directory: "sessions" | "oidc", rawValue:
return join(rootPath, directory, `${createHash("sha256").update(rawValue).digest("hex")}.json`);
}
function claimPath(rootPath: string, rawState: string): string {
return join(rootPath, "oidc", `${createHash("sha256").update(rawState).digest("hex")}.claim`);
}
function validStore(storageRoot: string): AuthSessionStore {
return createFileAuthSessionStore(storageRoot, {
currentAuthConfigRevision: () => revision,
findLocalUser: async () => validLocalUser,
});
}
async function create(
store: AuthSessionStore,
overrides: Partial<SessionCreateInput> = {},
@@ -69,10 +123,72 @@ async function expectStoreInvalid(operation: Promise<unknown>): Promise<void> {
await expect(operation).rejects.toThrow("auth_session_store_invalid");
}
async function isolatedOidcConsumer(storageRoot: string, state: string): Promise<{
start(): void;
result: Promise<boolean>;
}> {
const child = fork(new URL("./fixtures/oidc-state-consumer.mts", import.meta.url), [], {
cwd: process.cwd(),
execArgv: ["--import", "tsx"],
env: {
...process.env,
THT_TEST_SESSION_ROOT: storageRoot,
THT_TEST_OIDC_STATE: state,
},
silent: true,
});
const ready = new Promise<void>((resolve, reject) => {
child.once("message", (message) => {
if (message === "ready") resolve();
else reject(new Error("OIDC consumer did not become ready"));
});
child.once("error", reject);
child.once("exit", (code) => {
if (code !== null && code !== 0) reject(new Error("OIDC consumer exited before ready"));
});
});
const result = new Promise<boolean>((resolve, reject) => {
child.on("message", (message) => {
if (message && typeof message === "object" && "consumed" in message) {
const outcome = message as { consumed: unknown; failed?: unknown };
if (outcome.failed === true) reject(new Error("OIDC consumer failed"));
else resolve(outcome.consumed === true);
}
});
child.on("error", reject);
child.on("exit", (code) => {
if (code !== 0) reject(new Error("OIDC consumer exited without a result"));
});
});
await ready;
return { start: () => child.send("consume"), result };
}
describe("file-backed auth session store", () => {
test("creates 256-bit opaque tokens, digest-only files, and derived CSRF values", async () => {
test("fails closed and revokes a session when constructed without validity dependencies", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const created = await create(store);
await expect(store.resolve(created.token)).resolves.toBeUndefined();
expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false);
});
test("revokes a session when a validity dependency throws", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot, {
currentAuthConfigRevision: () => { throw new Error("dependency unavailable"); },
findLocalUser: async () => validLocalUser,
});
const created = await create(store);
await expectStoreInvalid(store.resolve(created.token));
expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false);
});
test("creates 256-bit opaque tokens, digest-only files, and derived CSRF values", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const first = await create(store);
const second = await create(store);
const path = digestPath(storageRoot, "sessions", first.token);
@@ -99,9 +215,9 @@ describe("file-backed auth session store", () => {
test("survives a backend restart and respects idle and absolute expiry", async () => {
const storageRoot = root();
const firstStore = createFileAuthSessionStore(storageRoot);
const firstStore = validStore(storageRoot);
const created = await create(firstStore);
const restartedStore = createFileAuthSessionStore(storageRoot);
const restartedStore = validStore(storageRoot);
await expect(restartedStore.resolve(created.token, new Date(base.getTime() + 9 * 60_000)))
.resolves.toMatchObject({ subject: created.record.subject, remembered: true });
@@ -116,7 +232,7 @@ describe("file-backed auth session store", () => {
test("touches at most once per five minutes and never extends absolute expiry", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const store = validStore(storageRoot);
const created = await create(store);
const path = digestPath(storageRoot, "sessions", created.token);
const before = readFileSync(path, "utf8");
@@ -134,7 +250,7 @@ describe("file-backed auth session store", () => {
test("revokes sessions and prunes expired session and OIDC-state records", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const store = validStore(storageRoot);
const revoked = await create(store);
const expired = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
const oidc = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
@@ -150,7 +266,7 @@ describe("file-backed auth session store", () => {
test("creates bounded OIDC state records that expire and are single-use", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const store = validStore(storageRoot);
const created = await store.createOidcState({
nonce: "n".repeat(43),
codeVerifier: "v".repeat(43),
@@ -169,9 +285,64 @@ describe("file-backed auth session store", () => {
.resolves.toBeUndefined();
});
test("fails closed when an OIDC state already has an atomic filesystem claim", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const statePath = digestPath(storageRoot, "oidc", created.state);
linkSync(statePath, claimPath(storageRoot, created.state));
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
expect(existsSync(statePath)).toBe(true);
});
test("prunes an expired OIDC state abandoned after an atomic claim", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const statePath = digestPath(storageRoot, "oidc", created.state);
const stateClaimPath = claimPath(storageRoot, created.state);
linkSync(statePath, stateClaimPath);
await expect(store.prune(new Date(base.getTime() + 10 * 60_000))).resolves.toBe(1);
expect(existsSync(statePath)).toBe(false);
expect(existsSync(stateClaimPath)).toBe(false);
});
test("retains an in-flight orphan claim but removes it after the bounded recovery window", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const statePath = digestPath(storageRoot, "oidc", created.state);
const stateClaimPath = claimPath(storageRoot, created.state);
linkSync(statePath, stateClaimPath);
unlinkSync(statePath);
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
expect(existsSync(stateClaimPath)).toBe(true);
await expect(store.prune(new Date("2031-01-02T03:04:05.000Z"))).resolves.toBe(1);
expect(existsSync(stateClaimPath)).toBe(false);
});
test("allows exactly one separate Node isolate to consume an OIDC state", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
const [first, second] = await Promise.all([
isolatedOidcConsumer(storageRoot, created.state),
isolatedOidcConsumer(storageRoot, created.state),
]);
first.start();
second.start();
const outcomes = await Promise.all([first.result, second.result]);
expect(outcomes.filter(Boolean)).toHaveLength(1);
expect(existsSync(digestPath(storageRoot, "oidc", created.state))).toBe(false);
});
test.skipIf(process.platform === "win32")("refuses symlinked and hard-linked session records", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const store = validStore(storageRoot);
const symlinked = await create(store);
const symlinkPath = digestPath(storageRoot, "sessions", symlinked.token);
const target = `${symlinkPath}.target`;
@@ -187,7 +358,7 @@ describe("file-backed auth session store", () => {
test("refuses malformed and oversized session records without disclosing their contents", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const store = validStore(storageRoot);
const malformed = await create(store);
const malformedPath = digestPath(storageRoot, "sessions", malformed.token);
writeFileSync(malformedPath, "{}", { encoding: "utf8", mode: 0o600 });
@@ -201,6 +372,136 @@ describe("file-backed auth session store", () => {
await expectStoreInvalid(store.resolve(oversized.token));
});
test.skipIf(process.platform === "win32")("refuses unsafe existing roots and storage subdirectories", async () => {
const unsafeRoot = root();
chmodSync(unsafeRoot, 0o755);
await expectStoreInvalid(create(validStore(unsafeRoot)));
const storageRoot = root();
const store = validStore(storageRoot);
const created = await create(store);
chmodSync(join(storageRoot, "sessions"), 0o755);
await expectStoreInvalid(store.resolve(created.token));
const outer = root();
const realRoot = join(outer, "real-auth");
mkdirSync(realRoot, { mode: 0o700 });
chmodSync(realRoot, 0o700);
const linkedRoot = join(outer, "linked-auth");
symlinkSync(realRoot, linkedRoot);
await expectStoreInvalid(create(validStore(linkedRoot)));
});
test.skipIf(process.platform === "win32")("refuses storage owned by a different identity", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await create(store);
const sessions = join(storageRoot, "sessions");
fsHooks.transformLstat = (observed, info) => {
if (observed !== sessions) return info;
const foreign = Object.create(info) as import("node:fs").Stats;
Object.defineProperty(foreign, "uid", { value: info.uid + 1 });
return foreign;
};
await expectStoreInvalid(store.resolve(created.token));
});
test.skipIf(process.platform === "win32")("refuses directory replacement during a session read", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await create(store);
const sessions = join(storageRoot, "sessions");
const replacement = join(storageRoot, "sessions-replacement");
fsHooks.afterRead = () => {
renameSync(sessions, replacement);
symlinkSync(replacement, sessions);
};
await expectStoreInvalid(store.resolve(created.token));
});
test("refuses file replacement during a touch", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await create(store);
const path = digestPath(storageRoot, "sessions", created.token);
const replacement = `${path}.replacement`;
fsHooks.afterWrite = () => {
writeFileSync(replacement, "{}", { encoding: "utf8", mode: 0o600 });
chmodSync(replacement, 0o600);
renameSync(replacement, path);
};
await expectStoreInvalid(store.touch(created.token, new Date(base.getTime() + 5 * 60_000)));
});
test.skipIf(process.platform === "win32")("refuses directory replacement during a touch", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await create(store);
const path = digestPath(storageRoot, "sessions", created.token);
const sessions = join(storageRoot, "sessions");
const replacement = join(storageRoot, "sessions-replacement");
fsHooks.afterLstat = (observed) => {
if (observed !== path) return false;
renameSync(sessions, replacement);
symlinkSync(replacement, sessions);
return true;
};
await expectStoreInvalid(store.touch(created.token, new Date(base.getTime() + 5 * 60_000)));
});
test("refuses file replacement during revoke", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await create(store);
const path = digestPath(storageRoot, "sessions", created.token);
const replacement = `${path}.replacement`;
writeFileSync(replacement, "{}", { encoding: "utf8", mode: 0o600 });
chmodSync(replacement, 0o600);
fsHooks.afterLstat = (observed) => {
if (observed !== path) return false;
renameSync(replacement, path);
return true;
};
await expectStoreInvalid(store.revoke(created.token));
expect(readFileSync(path, "utf8")).toBe("{}");
});
test.skipIf(process.platform === "win32")("refuses directory replacement during revoke", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await create(store);
const path = digestPath(storageRoot, "sessions", created.token);
const sessions = join(storageRoot, "sessions");
const replacement = join(storageRoot, "sessions-replacement");
fsHooks.afterLstat = (observed) => {
if (observed !== path) return false;
renameSync(sessions, replacement);
symlinkSync(replacement, sessions);
return true;
};
await expectStoreInvalid(store.revoke(created.token));
});
test("rejects native Windows storage before any state write", async () => {
const storageRoot = root();
const store = validStore(join(storageRoot, "windows-auth-state"));
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatform) throw new Error("platform descriptor unavailable");
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
try {
await expectStoreInvalid(create(store));
expect(existsSync(join(storageRoot, "windows-auth-state"))).toBe(false);
} finally {
Object.defineProperty(process, "platform", originalPlatform);
}
});
test("revokes on config, local-user, revision, enabled, or role mismatch before returning", async () => {
const storageRoot = root();
let currentRevision = revision;
@@ -235,8 +536,8 @@ describe("file-backed auth session store", () => {
test("serializes concurrent resolve and revoke without resurrecting a record", async () => {
const storageRoot = root();
const firstStore = createFileAuthSessionStore(storageRoot);
const secondStore = createFileAuthSessionStore(storageRoot);
const firstStore = validStore(storageRoot);
const secondStore = validStore(storageRoot);
const created = await create(firstStore);
await Promise.all([