366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
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<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(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<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("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",
|
|
"database.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" })), { 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");
|
|
});
|