import { createHash } from "node:crypto"; import { chmodSync, existsSync, linkSync, lstatSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { createFileAuthSessionStore, deriveCsrfToken, type AuthSessionStore, type SessionCreateInput, } from "../src/auth/session-store.js"; const roots: string[] = []; const base = new Date("2030-01-02T03:04:05.000Z"); const revision = "a".repeat(64); afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); function root(): string { const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-session-")); chmodSync(path, 0o700); roots.push(path); return path; } function digestPath(rootPath: string, directory: "sessions" | "oidc", rawValue: string): string { return join(rootPath, directory, `${createHash("sha256").update(rawValue).digest("hex")}.json`); } async function create( store: AuthSessionStore, overrides: Partial = {}, now = base, ) { return store.create({ principal: { issuer: "local", subject: "6ba7b810-9dad-4ed1-80b4-00c04fd430c8", displayName: "Local administrator", roles: ["admin"], permissions: ["session.use", "settings.manage"], isAdmin: true, }, method: "local", remembered: true, userAuthRevision: 7, authConfigRevision: revision, idleTtlMs: 10 * 60_000, absoluteTtlMs: 30 * 60_000, ...overrides, }, now); } async function expectStoreInvalid(operation: Promise): Promise { await expect(operation).rejects.toThrow("auth_session_store_invalid"); } describe("file-backed auth session store", () => { test("creates 256-bit opaque tokens, digest-only files, and derived CSRF values", async () => { const storageRoot = root(); const store = createFileAuthSessionStore(storageRoot); const first = await create(store); const second = await create(store); const path = digestPath(storageRoot, "sessions", first.token); const contents = readFileSync(path, "utf8"); expect(first.token).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(Buffer.from(first.token, "base64url")).toHaveLength(32); expect(second.token).not.toBe(first.token); expect(lstatSync(path).isFile()).toBe(true); expect(path).toMatch(/[a-f0-9]{64}\.json$/); expect(contents).not.toContain(first.token); expect(contents).not.toContain(first.csrfToken); expect(deriveCsrfToken(first.token)).toBe(first.csrfToken); expect(first.csrfToken).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(first.csrfToken).not.toBe(first.token); if (process.platform !== "win32") { expect(lstatSync(storageRoot).mode & 0o7777).toBe(0o700); expect(lstatSync(join(storageRoot, "sessions")).mode & 0o7777).toBe(0o700); expect(lstatSync(join(storageRoot, "oidc")).mode & 0o7777).toBe(0o700); expect(lstatSync(path).mode & 0o7777).toBe(0o600); } }); test("survives a backend restart and respects idle and absolute expiry", async () => { const storageRoot = root(); const firstStore = createFileAuthSessionStore(storageRoot); const created = await create(firstStore); const restartedStore = createFileAuthSessionStore(storageRoot); await expect(restartedStore.resolve(created.token, new Date(base.getTime() + 9 * 60_000))) .resolves.toMatchObject({ subject: created.record.subject, remembered: true }); await expect(restartedStore.resolve(created.token, new Date(base.getTime() + 10 * 60_000))) .resolves.toBeUndefined(); expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false); const absolute = await create(restartedStore, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 10 * 60_000 }); await expect(restartedStore.resolve(absolute.token, new Date(base.getTime() + 10 * 60_000))) .resolves.toBeUndefined(); }); test("touches at most once per five minutes and never extends absolute expiry", async () => { const storageRoot = root(); const store = createFileAuthSessionStore(storageRoot); const created = await create(store); const path = digestPath(storageRoot, "sessions", created.token); const before = readFileSync(path, "utf8"); await store.touch(created.token, new Date(base.getTime() + 4 * 60_000)); expect(readFileSync(path, "utf8")).toBe(before); await store.touch(created.token, new Date(base.getTime() + 5 * 60_000)); await expect(store.resolve(created.token, new Date(base.getTime() + 5 * 60_000))).resolves.toMatchObject({ lastSeenAt: "2030-01-02T03:09:05.000Z", idleExpiresAt: "2030-01-02T03:19:05.000Z", absoluteExpiresAt: "2030-01-02T03:34:05.000Z", }); }); test("revokes sessions and prunes expired session and OIDC-state records", async () => { const storageRoot = root(); const store = createFileAuthSessionStore(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); await store.revoke(revoked.token); await expect(store.resolve(revoked.token)).resolves.toBeUndefined(); expect(existsSync(digestPath(storageRoot, "sessions", revoked.token))).toBe(false); await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(2); expect(existsSync(digestPath(storageRoot, "sessions", expired.token))).toBe(false); expect(existsSync(digestPath(storageRoot, "oidc", oidc.state))).toBe(false); }); test("creates bounded OIDC state records that expire and are single-use", async () => { const storageRoot = root(); const store = createFileAuthSessionStore(storageRoot); const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/", }, base); const path = digestPath(storageRoot, "oidc", created.state); expect(created.state).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(readFileSync(path, "utf8")).not.toContain(created.state); await expect(store.consumeOidcState(created.state, new Date(base.getTime() + 9 * 60_000))) .resolves.toMatchObject({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }); await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined(); const expired = await store.createOidcState({ nonce: "x".repeat(43), codeVerifier: "y".repeat(43), returnTo: "/" }, base); await expect(store.consumeOidcState(expired.state, new Date(base.getTime() + 10 * 60_000))) .resolves.toBeUndefined(); }); test.skipIf(process.platform === "win32")("refuses symlinked and hard-linked session records", async () => { const storageRoot = root(); const store = createFileAuthSessionStore(storageRoot); const symlinked = await create(store); const symlinkPath = digestPath(storageRoot, "sessions", symlinked.token); const target = `${symlinkPath}.target`; renameSync(symlinkPath, target); symlinkSync(target, symlinkPath); await expectStoreInvalid(store.resolve(symlinked.token)); const hardLinked = await create(store); const hardLinkPath = digestPath(storageRoot, "sessions", hardLinked.token); linkSync(hardLinkPath, `${hardLinkPath}.link`); await expectStoreInvalid(store.resolve(hardLinked.token)); }); test("refuses malformed and oversized session records without disclosing their contents", async () => { const storageRoot = root(); const store = createFileAuthSessionStore(storageRoot); const malformed = await create(store); const malformedPath = digestPath(storageRoot, "sessions", malformed.token); writeFileSync(malformedPath, "{}", { encoding: "utf8", mode: 0o600 }); chmodSync(malformedPath, 0o600); await expectStoreInvalid(store.resolve(malformed.token)); const oversized = await create(store); const oversizedPath = digestPath(storageRoot, "sessions", oversized.token); writeFileSync(oversizedPath, "#".repeat(20_000), { encoding: "utf8", mode: 0o600 }); chmodSync(oversizedPath, 0o600); await expectStoreInvalid(store.resolve(oversized.token)); }); test("revokes on config, local-user, revision, enabled, or role mismatch before returning", async () => { const storageRoot = root(); let currentRevision = revision; let localUser: { enabled: boolean; authRevision: number; roles: readonly ("user" | "admin")[] } | undefined = { enabled: true, authRevision: 7, roles: ["admin"], }; const store = createFileAuthSessionStore(storageRoot, { currentAuthConfigRevision: () => currentRevision, findLocalUser: async () => localUser, }); const configChanged = await create(store); currentRevision = "b".repeat(64); await expect(store.resolve(configChanged.token)).resolves.toBeUndefined(); expect(existsSync(digestPath(storageRoot, "sessions", configChanged.token))).toBe(false); currentRevision = revision; for (const mismatch of [ undefined, { enabled: false, authRevision: 7, roles: ["admin"] as const }, { enabled: true, authRevision: 8, roles: ["admin"] as const }, { enabled: true, authRevision: 7, roles: ["user"] as const }, ]) { localUser = mismatch; const session = await create(store); await expect(store.resolve(session.token)).resolves.toBeUndefined(); expect(existsSync(digestPath(storageRoot, "sessions", session.token))).toBe(false); } }); test("serializes concurrent resolve and revoke without resurrecting a record", async () => { const storageRoot = root(); const firstStore = createFileAuthSessionStore(storageRoot); const secondStore = createFileAuthSessionStore(storageRoot); const created = await create(firstStore); await Promise.all([ ...Array.from({ length: 8 }, () => firstStore.resolve(created.token)), ...Array.from({ length: 8 }, () => secondStore.revoke(created.token)), ]); await expect(firstStore.resolve(created.token)).resolves.toBeUndefined(); expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false); }); });