import { afterEach, expect, test, vi } from "vitest"; import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { 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(tmpdir(), "thothii-auth-config-")); directories.push(directory); const file = join(directory, "auth.yaml"); writeFileSync(file, stringify(value), "utf8"); 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("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" })), "utf8"); 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("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" })), "utf8"); const provider = createAuthenticationConfigProvider(file); readHook.callback = () => renameSync(replacement, file); expect(provider.current().value.publicUrl).toBe("http://127.0.0.1:9999"); }); test("rejects input larger than one MiB", () => { const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`); expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid"); });