feat(auth): define strict installation authentication config
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
|
||||
import type { AuthMode } from "./types.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest { principal?: PrincipalContext }
|
||||
}
|
||||
|
||||
export function authPreHandler(mode: "none" | "mock" | "upstream") {
|
||||
export function authPreHandler(mode: AuthMode) {
|
||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (mode === "none") {
|
||||
req.principal = localPrincipal();
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { closeSync, constants, fstatSync, openSync, readSync, statSync } from "node:fs";
|
||||
import { parseDocument } from "yaml";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AuthenticationConfig,
|
||||
AuthenticationConfigProvider,
|
||||
AuthMode,
|
||||
LoadedAuthConfig,
|
||||
Permission,
|
||||
Role,
|
||||
} from "./types.js";
|
||||
|
||||
export type {
|
||||
AuthenticationConfig,
|
||||
AuthenticationConfigProvider,
|
||||
AuthMode,
|
||||
LoadedAuthConfig,
|
||||
Permission,
|
||||
Role,
|
||||
} from "./types.js";
|
||||
|
||||
const MAX_AUTH_CONFIG_BYTES = 1024 * 1024;
|
||||
const ROLES = ["user", "admin"] as const;
|
||||
const PERMISSIONS: readonly Permission[] = [
|
||||
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
||||
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
|
||||
];
|
||||
|
||||
const invalid = (): Error => new Error("authentication configuration is invalid");
|
||||
const nonEmptyText = z.string().min(1).max(512).refine(
|
||||
(value) => value.trim() === value && !/[\u0000-\u001f\u007f]/.test(value),
|
||||
);
|
||||
const positiveSeconds = z.number().int().min(1).max(365 * 24 * 60 * 60);
|
||||
const sessionSchema = z.strictObject({
|
||||
regularTtlSeconds: positiveSeconds.default(43_200),
|
||||
regularIdleSeconds: positiveSeconds.default(7_200),
|
||||
rememberTtlSeconds: positiveSeconds.default(2_592_000),
|
||||
rememberIdleSeconds: positiveSeconds.default(604_800),
|
||||
oidcTtlSeconds: positiveSeconds.default(28_800),
|
||||
});
|
||||
const roleSchema = z.enum(ROLES);
|
||||
const groupNameSchema = nonEmptyText.max(256);
|
||||
const groupRolesSchema = z.record(groupNameSchema, z.array(roleSchema).min(1));
|
||||
|
||||
const localSchema = z.strictObject({
|
||||
version: z.literal(1), mode: z.literal("local"), publicUrl: nonEmptyText, session: sessionSchema.optional(),
|
||||
local: z.strictObject({ usersFile: nonEmptyText.max(255) }),
|
||||
});
|
||||
const oidcSchema = z.strictObject({
|
||||
version: z.literal(1), mode: z.literal("oidc"), publicUrl: nonEmptyText, session: sessionSchema.optional(),
|
||||
oidc: z.strictObject({
|
||||
issuer: nonEmptyText, clientId: nonEmptyText, clientSecretRef: z.literal("THT_OIDC_CLIENT_SECRET"),
|
||||
scopes: z.array(nonEmptyText).min(1).max(16), groupsClaim: z.literal("groups"),
|
||||
}),
|
||||
groupCatalog: z.strictObject({
|
||||
driver: z.literal("authentik"), baseUrl: nonEmptyText, apiTokenRef: z.literal("THT_AUTHENTIK_API_TOKEN"),
|
||||
}),
|
||||
authorization: z.strictObject({ groupRoles: groupRolesSchema }),
|
||||
});
|
||||
|
||||
function readBoundedConfig(path: string): string {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const info = fstatSync(fd);
|
||||
if (!info.isFile() || info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
||||
const buffer = Buffer.allocUnsafe(MAX_AUTH_CONFIG_BYTES + 1);
|
||||
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
|
||||
if (bytesRead > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead));
|
||||
} catch {
|
||||
throw invalid();
|
||||
} finally {
|
||||
if (fd !== undefined) try { closeSync(fd); } catch { /* sanitized by design */ }
|
||||
}
|
||||
}
|
||||
|
||||
function loopbackHost(host: string): boolean {
|
||||
return host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host);
|
||||
}
|
||||
|
||||
function validOrigin(value: string, httpLoopbackAllowed: boolean): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "https:" || (httpLoopbackAllowed && url.protocol === "http:" && loopbackHost(url.hostname)))
|
||||
&& url.username.length === 0 && url.password.length === 0 && url.pathname === "/"
|
||||
&& url.search.length === 0 && url.hash.length === 0;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function validIssuer(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" && url.username.length === 0 && url.password.length === 0
|
||||
&& url.search.length === 0 && url.hash.length === 0;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function validUsersFile(value: string): boolean {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._-]*\.yaml$/.test(value);
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, nested]) => [key, canonicalize(nested)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function canonicalRevision(value: AuthenticationConfig): string {
|
||||
return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
|
||||
}
|
||||
|
||||
function parseAuthenticationConfig(source: string): AuthenticationConfig {
|
||||
try {
|
||||
const document = parseDocument(source, { uniqueKeys: true });
|
||||
if (document.errors.length > 0 || document.warnings.length > 0) throw invalid();
|
||||
const parsed = document.toJSON();
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw invalid();
|
||||
const config = parsed as Record<string, unknown>;
|
||||
const schema = config.mode === "local" ? localSchema : config.mode === "oidc" ? oidcSchema : undefined;
|
||||
if (!schema) throw invalid();
|
||||
const validated = schema.parse(config);
|
||||
const session = sessionSchema.parse(validated.session ?? {});
|
||||
if (!validOrigin(validated.publicUrl, true)) throw invalid();
|
||||
if (validated.mode === "local") {
|
||||
if (!validUsersFile(validated.local.usersFile)) throw invalid();
|
||||
return { ...validated, session };
|
||||
}
|
||||
if (!validIssuer(validated.oidc.issuer) || !validOrigin(validated.groupCatalog.baseUrl, false)) throw invalid();
|
||||
if (!validated.oidc.scopes.includes("openid")) throw invalid();
|
||||
const mappings = Object.entries(validated.authorization.groupRoles);
|
||||
if (mappings.length === 0 || mappings.filter(([, roles]) => roles.includes("admin")).length !== 1) throw invalid();
|
||||
return { ...validated, session };
|
||||
} catch { throw invalid(); }
|
||||
}
|
||||
|
||||
export function loadAuthenticationConfig(path: string): LoadedAuthConfig {
|
||||
if (typeof path !== "string" || path.length === 0 || path.trim() !== path || path.includes("\0")) throw invalid();
|
||||
const value = parseAuthenticationConfig(readBoundedConfig(path));
|
||||
return { value, revision: canonicalRevision(value), sourcePath: path };
|
||||
}
|
||||
|
||||
interface FileIdentity { dev: number; ino: number; size: number; mtimeMs: number }
|
||||
|
||||
function fileIdentity(path: string): FileIdentity {
|
||||
try {
|
||||
const info = statSync(path);
|
||||
if (!info.isFile()) throw invalid();
|
||||
return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs };
|
||||
} catch { throw invalid(); }
|
||||
}
|
||||
|
||||
function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
||||
}
|
||||
|
||||
export function createAuthenticationConfigProvider(path: string): AuthenticationConfigProvider {
|
||||
let cached: { identity: FileIdentity; loaded: LoadedAuthConfig } | undefined;
|
||||
return { current(): LoadedAuthConfig {
|
||||
const before = fileIdentity(path);
|
||||
if (cached && sameIdentity(cached.identity, before)) return cached.loaded;
|
||||
const loaded = loadAuthenticationConfig(path);
|
||||
cached = { identity: fileIdentity(path), loaded };
|
||||
return loaded;
|
||||
} };
|
||||
}
|
||||
|
||||
export function rolesToPermissions(roles: readonly Role[]): readonly Permission[] {
|
||||
const requested = new Set<Role>();
|
||||
for (const role of roles) {
|
||||
if (!ROLES.includes(role)) throw invalid();
|
||||
requested.add(role);
|
||||
}
|
||||
if (requested.has("admin")) return PERMISSIONS;
|
||||
return requested.has("user") ? ["session.use"] : [];
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export type AuthMode = "local" | "oidc" | "upstream" | "none" | "mock";
|
||||
|
||||
export type Role = "user" | "admin";
|
||||
|
||||
export type Permission =
|
||||
| "session.use" | "session.read_all" | "session.manage_all"
|
||||
| "settings.manage" | "workspace.manage" | "workspace.secrets.manage"
|
||||
| "pi.manage" | "auth.diagnostics.read";
|
||||
|
||||
export interface AuthenticationSessionConfig {
|
||||
regularTtlSeconds: number;
|
||||
regularIdleSeconds: number;
|
||||
rememberTtlSeconds: number;
|
||||
rememberIdleSeconds: number;
|
||||
oidcTtlSeconds: number;
|
||||
}
|
||||
|
||||
export interface LocalAuthenticationConfig {
|
||||
version: 1;
|
||||
mode: "local";
|
||||
publicUrl: string;
|
||||
session: AuthenticationSessionConfig;
|
||||
local: { usersFile: string };
|
||||
}
|
||||
|
||||
export interface OidcAuthenticationConfig {
|
||||
version: 1;
|
||||
mode: "oidc";
|
||||
publicUrl: string;
|
||||
session: AuthenticationSessionConfig;
|
||||
oidc: {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecretRef: "THT_OIDC_CLIENT_SECRET";
|
||||
scopes: readonly string[];
|
||||
groupsClaim: "groups";
|
||||
};
|
||||
groupCatalog: {
|
||||
driver: "authentik";
|
||||
baseUrl: string;
|
||||
apiTokenRef: "THT_AUTHENTIK_API_TOKEN";
|
||||
};
|
||||
authorization: { groupRoles: Readonly<Record<string, readonly Role[]>> };
|
||||
}
|
||||
|
||||
export type AuthenticationConfig = LocalAuthenticationConfig | OidcAuthenticationConfig;
|
||||
|
||||
export interface LoadedAuthConfig {
|
||||
value: AuthenticationConfig;
|
||||
revision: string;
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
export interface AuthenticationConfigProvider {
|
||||
current(): LoadedAuthConfig;
|
||||
}
|
||||
+52
-10
@@ -1,9 +1,18 @@
|
||||
import path from "node:path";
|
||||
import { statSync } from "node:fs";
|
||||
import {
|
||||
createAuthenticationConfigProvider,
|
||||
type AuthenticationConfigProvider,
|
||||
type AuthMode,
|
||||
} from "./auth/config.js";
|
||||
import type { WorkspaceRegistryConfig } from "./workspaces/types.js";
|
||||
|
||||
export interface AppConfig {
|
||||
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
|
||||
authMode: "none" | "mock" | "upstream";
|
||||
authMode: AuthMode;
|
||||
authConfigFile: string;
|
||||
authStateRoot: string;
|
||||
authentication?: AuthenticationConfigProvider;
|
||||
publicExposure: boolean;
|
||||
sessionStorage: {
|
||||
mode: "local" | "postgres";
|
||||
@@ -55,6 +64,23 @@ function absoluteRegistryPath(value: string, label: string): string {
|
||||
return pathValue;
|
||||
}
|
||||
|
||||
function absoluteAuthPath(value: string, label: string): string {
|
||||
if (value.length === 0 || value.trim() !== value || value.includes("\0") || !path.isAbsolute(value)) {
|
||||
throw new Error(`authentication ${label} configuration is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function authConfigFileExists(file: string): boolean {
|
||||
try {
|
||||
if (!statSync(file).isFile()) throw new Error("authentication configuration is invalid");
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error?.code === "ENOENT") return false;
|
||||
throw new Error("authentication configuration is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function refSafeGitBranch(value: string): string {
|
||||
const branch = requiredRegistryValue(value, "branch");
|
||||
if (
|
||||
@@ -149,13 +175,26 @@ function positiveDimension(value: string | undefined, fallback: number): number
|
||||
}
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
const authMode = env.AUTH_MODE ?? "none";
|
||||
if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) {
|
||||
throw new Error(`unsupported AUTH_MODE=${authMode}; use none, mock, or upstream`);
|
||||
const authConfigFile = absoluteAuthPath(env.THT_AUTH_CONFIG_FILE ?? "/run/thothii-auth/auth.yaml", "file");
|
||||
const authStateRoot = absoluteAuthPath(env.THT_AUTH_STATE_ROOT ?? "/data/auth", "state root");
|
||||
const hasAuthenticationConfig = authConfigFileExists(authConfigFile);
|
||||
if (hasAuthenticationConfig && env.AUTH_MODE !== undefined) {
|
||||
throw new Error("authentication configuration and AUTH_MODE cannot both be set");
|
||||
}
|
||||
const authentication = hasAuthenticationConfig ? createAuthenticationConfigProvider(authConfigFile) : undefined;
|
||||
let authMode: AuthMode;
|
||||
if (authentication) {
|
||||
authMode = authentication.current().value.mode;
|
||||
} else {
|
||||
const requestedMode = env.AUTH_MODE ?? "none";
|
||||
if (!(["none", "mock", "upstream"] as const).includes(requestedMode as "none" | "mock" | "upstream")) {
|
||||
throw new Error(`unsupported AUTH_MODE=${requestedMode}; use none, mock, or upstream`);
|
||||
}
|
||||
authMode = requestedMode as "none" | "mock" | "upstream";
|
||||
}
|
||||
const publicExposure = env.THOTH_PUBLIC_EXPOSURE === "true";
|
||||
if (publicExposure && authMode !== "upstream") {
|
||||
throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy");
|
||||
if (publicExposure && authMode !== "oidc" && authMode !== "upstream") {
|
||||
throw new Error("public exposure requires AUTH_MODE=upstream or configured OIDC behind a trusted proxy");
|
||||
}
|
||||
const sessionStorageMode = env.THT_SESSION_STORAGE ?? "local";
|
||||
if (sessionStorageMode !== "local" && sessionStorageMode !== "postgres") {
|
||||
@@ -181,15 +220,15 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
const sslrootcert = env.THT_SESSION_DB_SSLROOTCERT;
|
||||
const port = Number(env.THT_SESSION_DB_PORT ?? 5432);
|
||||
if (
|
||||
authMode !== "upstream"
|
||||
(authMode !== "upstream" && authMode !== "oidc")
|
||||
|| !host || !database || !runtimeUser
|
||||
|| !runtimePasswordFile || !path.isAbsolute(runtimePasswordFile)
|
||||
|| (sslmode !== "verify-ca" && sslmode !== "verify-full")
|
||||
|| !sslrootcert || !path.isAbsolute(sslrootcert)
|
||||
|| !Number.isInteger(port) || port < 1 || port > 65535
|
||||
) {
|
||||
if (authMode !== "upstream") {
|
||||
throw new Error("server session storage requires AUTH_MODE=upstream");
|
||||
if (authMode !== "upstream" && authMode !== "oidc") {
|
||||
throw new Error("server session storage requires AUTH_MODE=upstream or configured OIDC");
|
||||
}
|
||||
throw new Error("server session storage configuration is invalid");
|
||||
}
|
||||
@@ -277,7 +316,10 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
harnessDir: env.THT_HARNESS_DIR ?? "../harness",
|
||||
thtBin: env.THT_BIN ?? "tht",
|
||||
piBin: env.PI_BIN ?? "pi",
|
||||
authMode: authMode as AppConfig["authMode"],
|
||||
authMode,
|
||||
authConfigFile,
|
||||
authStateRoot,
|
||||
authentication,
|
||||
publicExposure,
|
||||
sessionStorage,
|
||||
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
import {
|
||||
createAuthenticationConfigProvider,
|
||||
loadAuthenticationConfig,
|
||||
rolesToPermissions,
|
||||
} from "../src/auth/config.js";
|
||||
|
||||
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("accepts the explicit loopback HTTP OIDC exception", () => {
|
||||
expect(loadAuthenticationConfig(writeFixture(oidcConfig({ publicUrl: "http://127.0.0.1:8787" }))).value.mode)
|
||||
.toBe("oidc");
|
||||
});
|
||||
|
||||
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("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("rejects input larger than one MiB", () => {
|
||||
const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`);
|
||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||
});
|
||||
@@ -1,6 +1,29 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
function authFile(value: unknown): { directory: string; file: string } {
|
||||
const directory = mkdtempSync(join(tmpdir(), "thothii-app-auth-config-"));
|
||||
const file = join(directory, "auth.yaml");
|
||||
writeFileSync(file, stringify(value), "utf8");
|
||||
return { directory, file };
|
||||
}
|
||||
|
||||
function oidcAuthConfig(): 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"], groupsClaim: "groups",
|
||||
},
|
||||
groupCatalog: { driver: "authentik", baseUrl: "https://authentik.example.org", apiTokenRef: "THT_AUTHENTIK_API_TOKEN" },
|
||||
authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } },
|
||||
};
|
||||
}
|
||||
|
||||
test("loadConfig accepts container listening and runtime paths", () => {
|
||||
expect(loadConfig({
|
||||
HOST: "0.0.0.0",
|
||||
@@ -41,10 +64,53 @@ test("loadConfig keeps local development defaults", () => {
|
||||
internalEmbeddingUrl: "http://embedding:11434",
|
||||
internalEmbeddingModel: "qwen3-embedding:0.6b",
|
||||
internalEmbeddingDimensions: 1024,
|
||||
authMode: "none",
|
||||
authConfigFile: "/run/thothii-auth/auth.yaml",
|
||||
authStateRoot: "/data/auth",
|
||||
});
|
||||
expect(loadConfig({}).dataRoot).toBeUndefined();
|
||||
});
|
||||
|
||||
test("loadConfig makes an existing auth.yaml authoritative and rejects AUTH_MODE split-brain", () => {
|
||||
const { directory, file } = authFile(oidcAuthConfig());
|
||||
try {
|
||||
const config = loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: "/state/auth" });
|
||||
expect(config.authMode).toBe("oidc");
|
||||
expect(config.authStateRoot).toBe("/state/auth");
|
||||
expect(config.authentication?.current().sourcePath).toBe(file);
|
||||
expect(() => loadConfig({ THT_AUTH_CONFIG_FILE: file, AUTH_MODE: "upstream" }))
|
||||
.toThrow("authentication configuration and AUTH_MODE cannot both be set");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loadConfig rejects an auth config path that exists but is not a regular file", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "thothii-app-auth-config-directory-"));
|
||||
try {
|
||||
expect(() => loadConfig({ THT_AUTH_CONFIG_FILE: directory }))
|
||||
.toThrow("authentication configuration is invalid");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("public exposure accepts configured OIDC and the upstream migration mode only", () => {
|
||||
const { directory, file } = authFile(oidcAuthConfig());
|
||||
try {
|
||||
expect(loadConfig({
|
||||
THOTH_PUBLIC_EXPOSURE: "true", THT_AUTH_CONFIG_FILE: file, THT_SESSION_STORAGE: "postgres",
|
||||
THT_SESSION_DB_HOST: "db.internal", THT_SESSION_DB_NAME: "thoth", THT_SESSION_RUNTIME_USER: "thoth_sessions_app",
|
||||
THT_SESSION_RUNTIME_PASSWORD_FILE: "/run/secrets/session_runtime_password", THT_SESSION_DB_SSLMODE: "verify-full",
|
||||
THT_SESSION_DB_SSLROOTCERT: "/run/secrets/session_ca.pem",
|
||||
}).authMode).toBe("oidc");
|
||||
expect(() => loadConfig({ THOTH_PUBLIC_EXPOSURE: "true", AUTH_MODE: "mock" }))
|
||||
.toThrow("public exposure requires AUTH_MODE=upstream or configured OIDC");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loadConfig accepts only the allowed internal semantic runtime hosts", () => {
|
||||
expect(loadConfig({
|
||||
THT_INTERNAL_QDRANT_URL: "http://localhost:6333",
|
||||
|
||||
Reference in New Issue
Block a user