Files
ThothII/backend/test/auth-config.test.ts
T

239 lines
8.8 KiB
TypeScript

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<typeof import("node:fs")>();
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<string, unknown> = {}): Record<string, unknown> {
return {
version: 1,
mode: "local",
publicUrl: "http://127.0.0.1:8080",
local: { usersFile: "users.yaml" },
...overrides,
};
}
function oidcConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
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<string, unknown>)
.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");
});