Publish documentation / publish (push) Successful in 1m27s
Add PostgreSQL-backed memory, editable evidence with source review and activation, and human-approved archive repairs across the harness, API, and UI. Include migrations, deployment support, regression coverage, and validation documentation. Refresh permissions from validated session roles so existing administrator logins can access newly deployed archive management features.
368 lines
14 KiB
TypeScript
368 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",
|
|
"memory.manage",
|
|
"evidence.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");
|
|
});
|