import { afterEach, expect, test, vi } from "vitest"; import { chmodSync, linkSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { stringify } from "yaml"; import { createAuthenticationConfigProvider, loadAuthenticationConfig, rolesToPermissions, } from "../src/auth/config.js"; const readHook = vi.hoisted(() => ({ callback: undefined as undefined | (() => void) })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readSync: (...args: any[]) => { const result = (actual.readSync as any)(...args); const callback = readHook.callback; readHook.callback = undefined; callback?.(); return result; }, }; }); const directories: string[] = []; afterEach(() => { for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); function writeFixture(value: unknown): string { const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-")); chmodSync(directory, 0o700); directories.push(directory); const file = join(directory, "auth.yaml"); writeFileSync(file, stringify(value), { encoding: "utf8", mode: 0o600 }); chmodSync(file, 0o600); return file; } function localConfig(overrides: Record = {}): Record { return { version: 1, mode: "local", publicUrl: "http://127.0.0.1:8080", local: { usersFile: "users.yaml" }, ...overrides, }; } function oidcConfig(overrides: Record = {}): Record { return { 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"], }, }, ...overrides, }; } test("loads local configuration with the specified default lifetimes", () => { const loaded = loadAuthenticationConfig(writeFixture(localConfig())); expect(loaded.value).toMatchObject({ mode: "local", publicUrl: "http://127.0.0.1:8080", session: { regularTtlSeconds: 43_200, regularIdleSeconds: 7_200, rememberTtlSeconds: 2_592_000, rememberIdleSeconds: 604_800, oidcTtlSeconds: 28_800, }, local: { usersFile: "users.yaml" }, }); expect(loaded.revision).toMatch(/^[a-f0-9]{64}$/); }); test("loads the fixed OIDC secret references and preserves exact group names", () => { const loaded = loadAuthenticationConfig(writeFixture(oidcConfig())); expect(loaded.value).toMatchObject({ mode: "oidc", oidc: { clientSecretRef: "THT_OIDC_CLIENT_SECRET", groupsClaim: "groups" }, groupCatalog: { driver: "authentik", apiTokenRef: "THT_AUTHENTIK_API_TOKEN" }, authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] }, }, }); expect(loaded.value.authorization.groupRoles["tot users"]).toBeUndefined(); }); test.each([ ["unknown root key", localConfig({ unexpected: true })], ["relative users file", localConfig({ local: { usersFile: "../users.yaml" } })], ["OIDC without groups claim", oidcConfig({ oidc: { issuer: "https://authentik.example.org/application/o/thothii/", clientId: "thothii", clientSecretRef: "THT_OIDC_CLIENT_SECRET", scopes: ["openid"], } })], ["OIDC without admin mapping", oidcConfig({ authorization: { groupRoles: {} } })], ["HTTP non-loopback public URL", localConfig({ publicUrl: "http://thoth.example" })], ])("rejects %s", (_label, value) => { expect(() => loadAuthenticationConfig(writeFixture(value))).toThrow("authentication configuration is invalid"); }); test.each([ "http://127.0.0.1:8787", "http://127.255.255.254:8787", "http://[::1]:8787", ])("accepts the literal loopback HTTP OIDC exception %s", (publicUrl) => { expect(loadAuthenticationConfig(writeFixture(oidcConfig({ publicUrl }))).value.mode).toBe("oidc"); }); test.each([ "http://localhost:8787", "http://loopback.example.test:8787", "http://user@127.0.0.1:8787", "http://127.1:8787", "http://127.0.0.01:8787", "http://0177.0.0.1:8787", "http://0x7f000001:8787", "http://2130706433:8787", "http://[::ffff:127.0.0.1]:8787", "http://128.0.0.1:8787", ])("rejects non-canonical or non-loopback HTTP public URL %s", (publicUrl) => { expect(() => loadAuthenticationConfig(writeFixture(oidcConfig({ publicUrl })))) .toThrow("authentication configuration is invalid"); }); test("rejects unknown roles and requires exactly one admin group", () => { expect(() => loadAuthenticationConfig(writeFixture(oidcConfig({ authorization: { groupRoles: { "TOT Users": ["user", "operator"], "TOT Admin": ["admin"] } }, })))).toThrow("authentication configuration is invalid"); expect(() => loadAuthenticationConfig(writeFixture(oidcConfig({ authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"], "Other Admin": ["admin"] } }, })))).toThrow("authentication configuration is invalid"); }); test("caps configured group mappings at the OIDC direct-groups bound", () => { const mappings = Object.fromEntries(Array.from({ length: 128 }, (_unused, index) => [ `Mapped Group ${String(index).padStart(3, "0")}`, index === 0 ? ["admin"] : ["user"], ])); expect(loadAuthenticationConfig(writeFixture(oidcConfig({ authorization: { groupRoles: mappings } }))).value.mode) .toBe("oidc"); mappings["Mapped Group overflow"] = ["user"]; expect(() => loadAuthenticationConfig(writeFixture(oidcConfig({ authorization: { groupRoles: mappings } })))) .toThrow("authentication configuration is invalid"); }); test("roles collapse duplicates and admin contains all administrative permissions", () => { expect(rolesToPermissions(["admin", "admin", "user"])).toEqual([ "session.use", "session.read_all", "session.manage_all", "settings.manage", "workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read", ]); expect(() => rolesToPermissions(["unknown"] as never)).toThrow("authentication configuration is invalid"); }); test("rejects duplicate YAML keys and does not leak invalid reference values", () => { const duplicate = writeFixture(`version: 1\nmode: local\nmode: oidc\npublicUrl: http://127.0.0.1:8787\nlocal:\n usersFile: users.yaml\n`); expect(() => loadAuthenticationConfig(duplicate)).toThrow("authentication configuration is invalid"); const referenceCanary = "never-load-or-emit-this-secret"; const invalid = writeFixture(oidcConfig({ oidc: { issuer: "https://authentik.example.org/application/o/thothii/", clientId: "thothii", clientSecretRef: referenceCanary, scopes: ["openid"], groupsClaim: "groups", } })); try { loadAuthenticationConfig(invalid); } catch (error) { expect(String(error)).not.toContain(referenceCanary); } }); test("canonical group map order produces one stable revision", () => { const first = writeFixture(oidcConfig()); const reordered = writeFixture(oidcConfig({ authorization: { groupRoles: { "TOT Admin": ["admin"], "TOT Users": ["user"] } }, })); expect(loadAuthenticationConfig(first).revision).toBe(loadAuthenticationConfig(reordered).revision); }); test("canonical revisions use code-unit ordering for non-ASCII group names", () => { const loaded = loadAuthenticationConfig(writeFixture(oidcConfig({ authorization: { groupRoles: { "Ångström users": ["user"], "Zebra admins": ["admin"] } }, }))); const canonicalize = (value: unknown): unknown => Array.isArray(value) ? value.map(canonicalize) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value as Record) .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) .map(([key, nested]) => [key, canonicalize(nested)])) : value; const expected = createHash("sha256").update(JSON.stringify(canonicalize(loaded.value))).digest("hex"); expect(loaded.revision).toBe(expected); }); test("provider reloads after an atomic configuration replacement", () => { const file = writeFixture(localConfig()); const provider = createAuthenticationConfigProvider(file); const original = provider.current(); const replacement = `${file}.replacement`; writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 }); chmodSync(replacement, 0o600); renameSync(replacement, file); const reloaded = provider.current(); expect(reloaded.revision).not.toBe(original.revision); expect(reloaded.value.publicUrl).toBe("http://127.0.0.1:9999"); }); test("loads and reloads Windows auth.yaml through the production storage bridge boundary", () => { const originalPlatform = process.platform; const windowsPath = "C:\\ProgramData\\ThothII\\auth\\auth.yaml"; let source = stringify(localConfig()); const readAuthConfig = vi.fn(() => Buffer.from(source)); Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); try { const options = { windowsStorageBridge: { readAuthConfig } as never }; expect(loadAuthenticationConfig(windowsPath, options).value.publicUrl).toBe("http://127.0.0.1:8080"); const provider = createAuthenticationConfigProvider(windowsPath, options); const original = provider.current(); source = stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })); const reloaded = provider.current(); expect(reloaded.value.publicUrl).toBe("http://127.0.0.1:9999"); expect(reloaded.revision).not.toBe(original.revision); expect(readAuthConfig).toHaveBeenCalledTimes(3); expect(readAuthConfig).toHaveBeenCalledWith(windowsPath); } finally { Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); } }); test("provider retries when replacement occurs between its read and cache identity check", () => { const file = writeFixture(localConfig()); const replacement = `${file}.replacement`; writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 }); chmodSync(replacement, 0o600); const provider = createAuthenticationConfigProvider(file); readHook.callback = () => renameSync(replacement, file); expect(provider.current().value.publicUrl).toBe("http://127.0.0.1:9999"); }); test.each(["symlink", "hard link", "mode wider than 0600", "non-private parent"])( "rejects auth.yaml with unsafe %s storage", (kind) => { const file = writeFixture(localConfig()); if (kind === "symlink") { const target = `${file}.target`; renameSync(file, target); symlinkSync(target, file); } else if (kind === "hard link") linkSync(file, `${file}.link`); else if (kind === "mode wider than 0600") chmodSync(file, 0o640); else chmodSync(dirname(file), 0o750); expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid"); }, ); test("rejects auth.yaml beneath a symlinked parent without exposing its path", () => { const outer = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-parent-")); chmodSync(outer, 0o700); directories.push(outer); const realDirectory = join(outer, "real-auth"); const linkedDirectory = join(outer, "linked-auth"); mkdirSync(realDirectory, { mode: 0o700 }); chmodSync(realDirectory, 0o700); const realFile = join(realDirectory, "auth.yaml"); writeFileSync(realFile, stringify(localConfig()), { encoding: "utf8", mode: 0o600 }); chmodSync(realFile, 0o600); symlinkSync(realDirectory, linkedDirectory); const unsafePath = join(linkedDirectory, "auth.yaml"); try { loadAuthenticationConfig(unsafePath); throw new Error("unsafe auth configuration unexpectedly loaded"); } catch (error) { expect((error as Error).message).toBe("authentication configuration is invalid"); expect(String(error)).not.toContain(unsafePath); } }); test("rejects auth.yaml when its owner is not the runtime owner", () => { const geteuid = process.geteuid; if (!geteuid) return; const owner = geteuid(); const file = writeFixture(localConfig()); const spy = vi.spyOn(process, "geteuid").mockReturnValue(owner + 1); try { expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid"); } finally { spy.mockRestore(); } }); test("provider redacts an absent canonical path", () => { const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-absent-")); chmodSync(directory, 0o700); directories.push(directory); const missing = join(directory, "private-path-UNIQUE-4K6.yaml"); try { createAuthenticationConfigProvider(missing).current(); throw new Error("missing authentication configuration unexpectedly loaded"); } catch (error) { expect((error as Error).message).toBe("authentication configuration is invalid"); expect(String(error)).not.toContain(missing); } }); test("rejects a path replacement during the bounded auth.yaml read", () => { const file = writeFixture(localConfig()); const replacement = `${file}.replacement`; writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 }); chmodSync(replacement, 0o600); readHook.callback = () => renameSync(replacement, file); expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid"); }); test("rejects input larger than one MiB", () => { const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`); expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid"); });