import { createHash } from "node:crypto"; import { chmodSync, chownSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { stringify } from "yaml"; import { afterEach, expect, test, vi } from "vitest"; import { createProjectedAuthenticationConfigProvider } from "../src/auth/runtime-projection.js"; import { createCurrentLocalUserRegistryResolver } from "../src/auth/local-registry.js"; import { loadConfig } from "../src/config.js"; const fsHook = vi.hoisted(() => ({ path: undefined as string | undefined, callback: undefined as (() => void) | undefined, fstatCallback: undefined as | ((value: import("node:fs").Stats) => void) | undefined, rejectPathReaddir: false, foreignGid: undefined as number | undefined, })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); const observed = (value: T): T => fsHook.foreignGid === undefined ? value : new Proxy(value, { get(target, property) { if (property === "gid") return fsHook.foreignGid; const member = Reflect.get(target, property, target); return typeof member === "function" ? member.bind(target) : member; }, }); return { ...actual, lstatSync(path: import("node:fs").PathLike) { const result = observed(actual.lstatSync(path)); if (fsHook.path === String(path)) fsHook.callback?.(); return result; }, fstatSync(fd: number) { const result = observed(actual.fstatSync(fd)); fsHook.fstatCallback?.(result); return result; }, readdirSync(path: import("node:fs").PathLike) { if (fsHook.rejectPathReaddir) throw new Error("path readdir is forbidden"); return actual.readdirSync(path); }, }; }); const sentinel = "$argon2id$synthetic-sentinel"; const password = "correct horse battery staple"; const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"; const userId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8"; const roots: string[] = []; const linuxTest = test.runIf(process.platform === "linux"); afterEach(() => { fsHook.path = undefined; fsHook.callback = undefined; fsHook.fstatCallback = undefined; fsHook.rejectPathReaddir = false; fsHook.foreignGid = undefined; for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); interface ProjectionFixture { username: string; hash?: string; publicUrl?: string; } function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } function generationFor(auth: string, users: string): string { return sha256( `thothii-auth-projection-v1\nmode=local\nauth=${sha256(auth)}\nusers=${sha256(users)}\n`, ); } function localProjectionFixture( username: string, hash = passwordHash, ): ProjectionFixture { return { username, hash }; } function projectionRoot(): string { const root = mkdtempSync(join(tmpdir(), "tht-auth-projection-")); chmodSync(root, 0o700); roots.push(root); return root; } function currentDocument( generation: string, previousGenerations: readonly string[] = [], ): string { return `${JSON.stringify({ version: 1, state: "ready", transaction: "a".repeat(32), generation, ...(previousGenerations.length === 0 ? {} : { previousGenerations }), })}\n`; } function projectionSources(fixture: ProjectionFixture): { auth: string; users: string; } { return { auth: stringify({ version: 1, mode: "local", publicUrl: fixture.publicUrl ?? "http://127.0.0.1:8080", local: { usersFile: "users.yaml" }, }), users: stringify({ version: 1, users: [ { id: userId, username: fixture.username, passwordHash: fixture.hash ?? passwordHash, roles: ["admin"], enabled: true, authRevision: 1, }, ], }), }; } function writeGeneration(root: string, fixture: ProjectionFixture): string { const { auth, users } = projectionSources(fixture); const generation = generationFor(auth, users); const directory = join(root, "generations", generation); mkdirSync(directory, { recursive: true, mode: 0o700 }); chmodSync(directory, 0o700); const manifest = `${JSON.stringify({ version: 1, generation, mode: "local", canonicalRevision: `sha256:${generation}`, files: [ { name: "auth.yaml", size: Buffer.byteLength(auth), sha256: sha256(auth), }, { name: "users.yaml", size: Buffer.byteLength(users), sha256: sha256(users), }, ], })}\n`; for (const [name, source] of [ ["manifest.json", manifest], ["auth.yaml", auth], ["users.yaml", users], ] as const) { writeFileSync(join(directory, name), source, { encoding: "utf8", mode: 0o600, }); chmodSync(join(directory, name), 0o600); } return generation; } function writeReadyProjection( root: string, fixture: ProjectionFixture, ): string { mkdirSync(join(root, "generations"), { mode: 0o700 }); chmodSync(join(root, "generations"), 0o700); const generation = writeGeneration(root, fixture); writeFileSync(join(root, "CURRENT"), currentDocument(generation), { encoding: "utf8", mode: 0o600, }); chmodSync(join(root, "CURRENT"), 0o600); return generation; } function writeReadyOidcProjection(root: string): string { mkdirSync(join(root, "generations"), { mode: 0o700 }); chmodSync(join(root, "generations"), 0o700); const auth = stringify({ version: 1, mode: "oidc", publicUrl: "https://thothii.example.org", oidc: { issuer: "https://authentik.example.org/application/o/thothii/", clientId: "thothii", clientSecretRef: "THT_OIDC_CLIENT_SECRET", scopes: ["openid", "profile", "email"], groupsClaim: "groups", }, groupCatalog: { driver: "authentik", baseUrl: "https://authentik.example.org", apiTokenRef: "THT_AUTHENTIK_API_TOKEN", }, authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] }, }, }); const generation = sha256( `thothii-auth-projection-v1\nmode=oidc\nauth=${sha256(auth)}\nusers=-\n`, ); const directory = join(root, "generations", generation); mkdirSync(directory, { mode: 0o700 }); chmodSync(directory, 0o700); const manifest = `${JSON.stringify({ version: 1, generation, mode: "oidc", canonicalRevision: `sha256:${generation}`, files: [ { name: "auth.yaml", size: Buffer.byteLength(auth), sha256: sha256(auth), }, ], })}\n`; writeFileSync(join(directory, "manifest.json"), manifest, { encoding: "utf8", mode: 0o600, }); writeFileSync(join(directory, "auth.yaml"), auth, { encoding: "utf8", mode: 0o600, }); chmodSync(join(directory, "manifest.json"), 0o600); chmodSync(join(directory, "auth.yaml"), 0o600); writeFileSync(join(root, "CURRENT"), currentDocument(generation), { encoding: "utf8", mode: 0o600, }); chmodSync(join(root, "CURRENT"), 0o600); return generation; } function expectDenied(operation: () => unknown): void { try { operation(); throw new Error("operation unexpectedly succeeded"); } catch (error) { const message = error instanceof Error ? error.message : String(error); expect(message).toBe("authentication runtime projection is invalid"); expect(message).not.toContain(sentinel); expect(message).not.toContain(passwordHash); } } linuxTest("loads ready projection as one immutable auth and local-users snapshot", () => { const root = projectionRoot(); const fixture = localProjectionFixture("synthetic-user", passwordHash); const generation = writeReadyProjection(root, fixture); const loaded = createProjectedAuthenticationConfigProvider(root).current(); expect(loaded.revision).toBe(generation); expect(loaded.sourcePath).toBe( join(root, "generations", generation, "auth.yaml"), ); expect(loaded.runtimeProjection?.generation).toBe(generation); expect(loaded.runtimeProjection?.canonicalRevision).toBe( `sha256:${generation}`, ); expect(Object.isFrozen(loaded.runtimeProjection)).toBe(true); expect(Object.isFrozen(loaded.runtimeProjection?.localUsers)).toBe(true); expect(Object.isFrozen(loaded.runtimeProjection?.localUsers?.[0])).toBe(true); expect( Object.isFrozen(loaded.runtimeProjection?.localUsers?.[0]?.roles), ).toBe(true); }); linuxTest("loads a complete OIDC projection without a users snapshot", () => { const root = projectionRoot(); const generation = writeReadyOidcProjection(root); const loaded = createProjectedAuthenticationConfigProvider(root).current(); expect(loaded.value.mode).toBe("oidc"); expect(loaded.runtimeProjection).toEqual({ generation, canonicalRevision: `sha256:${generation}`, }); }); linuxTest("loadConfig selects an immutable projected local provider and its in-memory registry", async () => { const root = projectionRoot(); writeReadyProjection(root, localProjectionFixture("projected-user", passwordHash)); const config = loadConfig({ THT_AUTH_RUNTIME_PROJECTION_ROOT: root, THT_AUTH_STATE_ROOT: "/state/auth", }); const loaded = config.authentication?.current(); expect(loaded).toMatchObject({ value: { mode: "local" }, runtimeProjection: expect.any(Object) }); const registry = createCurrentLocalUserRegistryResolver().resolve(loaded!); expect(await registry?.findByUsername("PROJECTED-USER")).toMatchObject({ username: "projected-user" }); }); linuxTest("loadConfig selects an immutable projected OIDC provider without direct-file fallback", () => { const root = projectionRoot(); writeReadyOidcProjection(root); const config = loadConfig({ THT_AUTH_RUNTIME_PROJECTION_ROOT: root, THT_AUTH_CONFIG_FILE: "/run/thothii-auth/auth.yaml", THT_AUTH_STATE_ROOT: "/state/auth", }); expect(config.authentication?.current()).toMatchObject({ value: { mode: "oidc" }, runtimeProjection: expect.any(Object), }); }); linuxTest("rejects a trailing-slash runtime root", () => { const root = projectionRoot(); writeReadyProjection( root, localProjectionFixture("synthetic-user", passwordHash), ); expectDenied(() => createProjectedAuthenticationConfigProvider(`${root}/`).current(), ); }); linuxTest.each([ ["missing", undefined], [ "blocked", `${JSON.stringify({ version: 1, state: "blocked", transaction: "a".repeat(32) })}\n`, ], ["malformed", "{not-json}\n"], [ "duplicate-field", `{"version":1,"version":1,"state":"ready","transaction":"${"a".repeat(32)}","generation":"${"b".repeat(64)}"}\n`, ], [ "unknown-version", `${JSON.stringify({ version: 2, state: "ready", transaction: "a".repeat(32), generation: "b".repeat(64) })}\n`, ], ])("rejects %s CURRENT without secret disclosure", (_label, contents) => { const root = projectionRoot(); writeReadyProjection( root, localProjectionFixture("synthetic-user", sentinel), ); if (contents === undefined) unlinkSync(join(root, "CURRENT")); else writeFileSync(join(root, "CURRENT"), contents, { encoding: "utf8", mode: 0o600, }); expectDenied(() => createProjectedAuthenticationConfigProvider(root).current(), ); }); linuxTest.each([ "root traversal", "CURRENT symlink", "CURRENT hardlink", "permissive mode", "unexpected root entry", ])("rejects %s without secret disclosure", (kind) => { const root = projectionRoot(); writeReadyProjection( root, localProjectionFixture("synthetic-user", sentinel), ); const current = join(root, "CURRENT"); if (kind === "root traversal") { expectDenied(() => createProjectedAuthenticationConfigProvider( `${root}/../${root.split("/").at(-1)!}`, ).current(), ); return; } if (kind === "CURRENT symlink") { renameSync(current, join(root, "current-target")); symlinkSync(join(root, "current-target"), current); } else if (kind === "CURRENT hardlink") { linkSync(current, join(root, "current-link")); } else if (kind === "permissive mode") { chmodSync(current, 0o640); } else { writeFileSync(join(root, "unexpected"), "x", { encoding: "utf8", mode: 0o600, }); } expectDenied(() => createProjectedAuthenticationConfigProvider(root).current(), ); }); test.runIf(process.geteuid?.() === 0)( "rejects wrong owner at every projection boundary without secret disclosure", () => { const root = projectionRoot(); writeReadyProjection( root, localProjectionFixture("synthetic-user", sentinel), ); // Root may safely construct a synthetic foreign-owned fixture; no real account is touched. chownSync(join(root, "CURRENT"), 1, 1); expectDenied(() => createProjectedAuthenticationConfigProvider(root).current(), ); }, ); linuxTest("rejects a foreign group with the correct owner", () => { const root = projectionRoot(); writeReadyProjection( root, localProjectionFixture("synthetic-user", passwordHash), ); const gid = process.getegid?.() ?? 0; fsHook.foreignGid = gid === 1 ? 2 : 1; expectDenied(() => createProjectedAuthenticationConfigProvider(root).current(), ); }); linuxTest("enumerates closed namespaces without path-based readdirSync", () => { const root = projectionRoot(); const generation = writeReadyProjection( root, localProjectionFixture("synthetic-user", passwordHash), ); fsHook.rejectPathReaddir = true; expect( createProjectedAuthenticationConfigProvider(root).current() .runtimeProjection?.generation, ).toBe(generation); }); linuxTest("rejects a symlinked runtime root", () => { const root = projectionRoot(); writeReadyProjection( root, localProjectionFixture("synthetic-user", passwordHash), ); const linkedRoot = `${root}-link`; symlinkSync(root, linkedRoot, "dir"); roots.push(linkedRoot); expectDenied(() => createProjectedAuthenticationConfigProvider(linkedRoot).current(), ); }); linuxTest.each([ "root", "generations", "selected generation", "manifest", "auth", "users", ])("rejects unsafe mode on %s", (boundary) => { const root = projectionRoot(); const generation = writeReadyProjection( root, localProjectionFixture("synthetic-user", passwordHash), ); const selected = join(root, "generations", generation); const path = boundary === "root" ? root : boundary === "generations" ? join(root, "generations") : boundary === "selected generation" ? selected : join( selected, boundary === "manifest" ? "manifest.json" : `${boundary}.yaml`, ); chmodSync( path, boundary === "root" || boundary === "generations" || boundary === "selected generation" ? 0o750 : 0o640, ); expectDenied(() => createProjectedAuthenticationConfigProvider(root).current(), ); }); linuxTest.each(["manifest", "generation", "size", "digest"])( "rejects changed %s integrity data without secret disclosure", (kind) => { const root = projectionRoot(); const generation = writeReadyProjection( root, localProjectionFixture("synthetic-user", sentinel), ); const manifestPath = join(root, "generations", generation, "manifest.json"); const manifest = JSON.parse(String(readFileSync(manifestPath))) as Record< string, any >; if (kind === "manifest") manifest.unexpected = true; if (kind === "generation") manifest.generation = "b".repeat(64); if (kind === "size") manifest.files[0].size += 1; if (kind === "digest") manifest.files[1].sha256 = "b".repeat(64); writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { encoding: "utf8", mode: 0o600, }); chmodSync(manifestPath, 0o600); expectDenied(() => createProjectedAuthenticationConfigProvider(root).current(), ); }, ); linuxTest("switches atomically to a later complete generation", () => { const root = projectionRoot(); const first = writeReadyProjection( root, localProjectionFixture("first", passwordHash), ); const provider = createProjectedAuthenticationConfigProvider(root); expect(provider.current().runtimeProjection?.generation).toBe(first); const second = writeGeneration( root, localProjectionFixture("second", passwordHash), ); const temporary = join(root, ".current-switch.tmp"); writeFileSync(temporary, currentDocument(second, [first]), { encoding: "utf8", mode: 0o600, }); chmodSync(temporary, 0o600); renameSync(temporary, join(root, "CURRENT")); expect(provider.current().runtimeProjection?.generation).toBe(second); }); linuxTest("retries once when CURRENT is atomically replaced between lstat and open", () => { const root = projectionRoot(); const first = writeReadyProjection( root, localProjectionFixture("first", passwordHash), ); const second = writeGeneration( root, localProjectionFixture("second", passwordHash), ); const temporary = join(root, ".current-replacement.tmp"); fsHook.path = join(root, "CURRENT"); fsHook.callback = () => { fsHook.callback = undefined; writeFileSync(temporary, currentDocument(second, [first]), { encoding: "utf8", mode: 0o600, }); chmodSync(temporary, 0o600); renameSync(temporary, join(root, "CURRENT")); }; expect( createProjectedAuthenticationConfigProvider(root).current() .runtimeProjection?.generation, ).toBe(second); }); linuxTest("retries once when CURRENT is replaced after the final identity read", () => { const root = projectionRoot(); const first = writeReadyProjection( root, localProjectionFixture("first", passwordHash), ); const second = writeGeneration( root, localProjectionFixture("second", passwordHash), ); const stagedParent = mkdtempSync( join(tmpdir(), "tht-auth-projection-late-generation-"), ); roots.push(stagedParent); renameSync(join(root, "generations", second), join(stagedParent, second)); let observations = 0; fsHook.path = join(root, "CURRENT"); fsHook.callback = () => { observations += 1; if (observations !== 3) return; fsHook.callback = undefined; renameSync(join(stagedParent, second), join(root, "generations", second)); const temporary = join(root, ".current-late-replacement.tmp"); writeFileSync(temporary, currentDocument(second, [first]), { encoding: "utf8", mode: 0o600, }); chmodSync(temporary, 0o600); renameSync(temporary, join(root, "CURRENT")); }; expect( createProjectedAuthenticationConfigProvider(root).current() .runtimeProjection?.generation, ).toBe(second); expect(observations).toBe(3); }); linuxTest("retries once when CURRENT is replaced between root descriptor and path observations", () => { const root = projectionRoot(); const first = writeReadyProjection( root, localProjectionFixture("first", passwordHash), ); const second = writeGeneration( root, localProjectionFixture("second", passwordHash), ); const stagedParent = mkdtempSync( join(tmpdir(), "tht-auth-projection-root-observation-"), ); roots.push(stagedParent); renameSync(join(root, "generations", second), join(stagedParent, second)); const rootIdentity = lstatSync(root); let armed = false; let replacedCurrent = false; fsHook.path = join(root, "generations", first, "users.yaml"); fsHook.callback = () => { armed = true; fsHook.callback = undefined; }; fsHook.fstatCallback = (value) => { if ( !armed || replacedCurrent || value.dev !== rootIdentity.dev || value.ino !== rootIdentity.ino ) return; replacedCurrent = true; renameSync(join(stagedParent, second), join(root, "generations", second)); const temporary = join(root, ".current-root-observation.tmp"); writeFileSync(temporary, currentDocument(second, [first]), { encoding: "utf8", mode: 0o600, }); chmodSync(temporary, 0o600); renameSync(temporary, join(root, "CURRENT")); }; expect( createProjectedAuthenticationConfigProvider(root).current() .runtimeProjection?.generation, ).toBe(second); expect(replacedCurrent).toBe(true); }); linuxTest("rejects a second CURRENT replacement after the one permitted retry", () => { const root = projectionRoot(); const first = writeReadyProjection( root, localProjectionFixture("first", passwordHash), ); const second = writeGeneration( root, localProjectionFixture("second", passwordHash), ); const third = writeGeneration( root, localProjectionFixture("third", passwordHash), ); const replacements = [ { generation: second, previous: [first] }, { generation: third, previous: [second, first] }, ]; fsHook.path = join(root, "CURRENT"); fsHook.callback = () => { const replacement = replacements.shift(); if (!replacement) return; const temporary = join( root, `.current-replacement-${replacement.generation}.tmp`, ); writeFileSync( temporary, currentDocument(replacement.generation, replacement.previous), { encoding: "utf8", mode: 0o600 }, ); chmodSync(temporary, 0o600); renameSync(temporary, join(root, "CURRENT")); }; expectDenied(() => createProjectedAuthenticationConfigProvider(root).current(), ); }); linuxTest("fails deterministically when generations is replaced during a load", () => { const root = projectionRoot(); const generation = writeReadyProjection( root, localProjectionFixture("synthetic-user", passwordHash), ); const replacement = mkdtempSync( join(tmpdir(), "tht-auth-projection-replacement-"), ); roots.push(replacement); chmodSync(replacement, 0o700); mkdirSync(join(replacement, generation), { recursive: true, mode: 0o700 }); chmodSync(join(replacement, generation), 0o700); for (const name of ["manifest.json", "auth.yaml", "users.yaml"]) { const source = join(root, "generations", generation, name); writeFileSync(join(replacement, generation, name), readFileSync(source), { mode: 0o600, }); chmodSync(join(replacement, generation, name), 0o600); } fsHook.path = join(root, "generations"); fsHook.callback = () => { fsHook.callback = undefined; renameSync(join(root, "generations"), join(root, "generations-retired")); renameSync(replacement, join(root, "generations")); }; expectDenied(() => createProjectedAuthenticationConfigProvider(root).current(), ); }); linuxTest("fails deterministically when the selected generation directory is replaced during a load", () => { const root = projectionRoot(); const generation = writeReadyProjection( root, localProjectionFixture("synthetic-user", passwordHash), ); const replacement = mkdtempSync( join(tmpdir(), "tht-auth-projection-generation-replacement-"), ); roots.push(replacement); chmodSync(replacement, 0o700); for (const name of ["manifest.json", "auth.yaml", "users.yaml"]) { const source = join(root, "generations", generation, name); writeFileSync(join(replacement, name), readFileSync(source), { mode: 0o600, }); chmodSync(join(replacement, name), 0o600); } fsHook.path = join(root, "generations", generation); fsHook.callback = () => { fsHook.callback = undefined; renameSync( join(root, "generations", generation), join(root, "generation-retired"), ); renameSync(replacement, join(root, "generations", generation)); }; expectDenied(() => createProjectedAuthenticationConfigProvider(root).current(), ); }); linuxTest.each(["corrupt", "symlink"])( "rejects a %s retained predecessor generation", (kind) => { const root = projectionRoot(); const first = writeReadyProjection( root, localProjectionFixture("first", passwordHash), ); const second = writeGeneration( root, localProjectionFixture("second", passwordHash), ); writeFileSync(join(root, "CURRENT"), currentDocument(second, [first]), { encoding: "utf8", mode: 0o600, }); chmodSync(join(root, "CURRENT"), 0o600); const predecessor = join(root, "generations", first); if (kind === "corrupt") { writeFileSync(join(predecessor, "auth.yaml"), "tampered", { encoding: "utf8", mode: 0o600, }); chmodSync(join(predecessor, "auth.yaml"), 0o600); } else { const replacement = mkdtempSync( join(tmpdir(), "tht-auth-projection-history-"), ); roots.push(replacement); chmodSync(replacement, 0o700); rmSync(predecessor, { recursive: true, force: true }); symlinkSync(replacement, predecessor, "dir"); } expectDenied(() => createProjectedAuthenticationConfigProvider(root).current(), ); }, ); linuxTest("has no direct-file fallback when CURRENT is absent", () => { const root = projectionRoot(); const generation = writeReadyProjection( root, localProjectionFixture("synthetic-user", sentinel), ); unlinkSync(join(root, "CURRENT")); writeFileSync( join(root, "auth.yaml"), projectionSources(localProjectionFixture("synthetic-user", sentinel)).auth, { mode: 0o600 }, ); expect(existsSync(join(root, "generations", generation, "auth.yaml"))).toBe( true, ); expectDenied(() => createProjectedAuthenticationConfigProvider(root).current(), ); }); linuxTest("in-flight snapshot authenticates A after selection B and deletion A, while a new load sees B", async () => { const root = projectionRoot(); const first = writeReadyProjection( root, localProjectionFixture("first", passwordHash), ); const provider = createProjectedAuthenticationConfigProvider(root); const loadedA = provider.current(); const second = writeGeneration( root, localProjectionFixture("second", passwordHash), ); const temporary = join(root, ".current-switch.tmp"); writeFileSync(temporary, currentDocument(second), { encoding: "utf8", mode: 0o600, }); chmodSync(temporary, 0o600); renameSync(temporary, join(root, "CURRENT")); rmSync(join(root, "generations", first), { recursive: true, force: true }); const resolver = createCurrentLocalUserRegistryResolver(); const registryA = resolver.resolve(loadedA); const userA = await registryA?.findByUsername("FIRST"); await expect(registryA?.verify(userA, password)).resolves.toBe(true); const loadedB = provider.current(); const registryB = resolver.resolve(loadedB); await expect(registryB?.findByUsername("second")).resolves.toMatchObject({ username: "second", }); await expect(registryB?.findByUsername("first")).resolves.toBeUndefined(); });