feat(auth): define strict installation authentication config
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||||
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
|
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
|
||||||
|
import type { AuthMode } from "./types.js";
|
||||||
|
|
||||||
declare module "fastify" {
|
declare module "fastify" {
|
||||||
interface FastifyRequest { principal?: PrincipalContext }
|
interface FastifyRequest { principal?: PrincipalContext }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authPreHandler(mode: "none" | "mock" | "upstream") {
|
export function authPreHandler(mode: AuthMode) {
|
||||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
if (mode === "none") {
|
if (mode === "none") {
|
||||||
req.principal = localPrincipal();
|
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 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";
|
import type { WorkspaceRegistryConfig } from "./workspaces/types.js";
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
|
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
|
||||||
authMode: "none" | "mock" | "upstream";
|
authMode: AuthMode;
|
||||||
|
authConfigFile: string;
|
||||||
|
authStateRoot: string;
|
||||||
|
authentication?: AuthenticationConfigProvider;
|
||||||
publicExposure: boolean;
|
publicExposure: boolean;
|
||||||
sessionStorage: {
|
sessionStorage: {
|
||||||
mode: "local" | "postgres";
|
mode: "local" | "postgres";
|
||||||
@@ -55,6 +64,23 @@ function absoluteRegistryPath(value: string, label: string): string {
|
|||||||
return pathValue;
|
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 {
|
function refSafeGitBranch(value: string): string {
|
||||||
const branch = requiredRegistryValue(value, "branch");
|
const branch = requiredRegistryValue(value, "branch");
|
||||||
if (
|
if (
|
||||||
@@ -149,13 +175,26 @@ function positiveDimension(value: string | undefined, fallback: number): number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||||
const authMode = env.AUTH_MODE ?? "none";
|
const authConfigFile = absoluteAuthPath(env.THT_AUTH_CONFIG_FILE ?? "/run/thothii-auth/auth.yaml", "file");
|
||||||
if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) {
|
const authStateRoot = absoluteAuthPath(env.THT_AUTH_STATE_ROOT ?? "/data/auth", "state root");
|
||||||
throw new Error(`unsupported AUTH_MODE=${authMode}; use none, mock, or upstream`);
|
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";
|
const publicExposure = env.THOTH_PUBLIC_EXPOSURE === "true";
|
||||||
if (publicExposure && authMode !== "upstream") {
|
if (publicExposure && authMode !== "oidc" && authMode !== "upstream") {
|
||||||
throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy");
|
throw new Error("public exposure requires AUTH_MODE=upstream or configured OIDC behind a trusted proxy");
|
||||||
}
|
}
|
||||||
const sessionStorageMode = env.THT_SESSION_STORAGE ?? "local";
|
const sessionStorageMode = env.THT_SESSION_STORAGE ?? "local";
|
||||||
if (sessionStorageMode !== "local" && sessionStorageMode !== "postgres") {
|
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 sslrootcert = env.THT_SESSION_DB_SSLROOTCERT;
|
||||||
const port = Number(env.THT_SESSION_DB_PORT ?? 5432);
|
const port = Number(env.THT_SESSION_DB_PORT ?? 5432);
|
||||||
if (
|
if (
|
||||||
authMode !== "upstream"
|
(authMode !== "upstream" && authMode !== "oidc")
|
||||||
|| !host || !database || !runtimeUser
|
|| !host || !database || !runtimeUser
|
||||||
|| !runtimePasswordFile || !path.isAbsolute(runtimePasswordFile)
|
|| !runtimePasswordFile || !path.isAbsolute(runtimePasswordFile)
|
||||||
|| (sslmode !== "verify-ca" && sslmode !== "verify-full")
|
|| (sslmode !== "verify-ca" && sslmode !== "verify-full")
|
||||||
|| !sslrootcert || !path.isAbsolute(sslrootcert)
|
|| !sslrootcert || !path.isAbsolute(sslrootcert)
|
||||||
|| !Number.isInteger(port) || port < 1 || port > 65535
|
|| !Number.isInteger(port) || port < 1 || port > 65535
|
||||||
) {
|
) {
|
||||||
if (authMode !== "upstream") {
|
if (authMode !== "upstream" && authMode !== "oidc") {
|
||||||
throw new Error("server session storage requires AUTH_MODE=upstream");
|
throw new Error("server session storage requires AUTH_MODE=upstream or configured OIDC");
|
||||||
}
|
}
|
||||||
throw new Error("server session storage configuration is invalid");
|
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",
|
harnessDir: env.THT_HARNESS_DIR ?? "../harness",
|
||||||
thtBin: env.THT_BIN ?? "tht",
|
thtBin: env.THT_BIN ?? "tht",
|
||||||
piBin: env.PI_BIN ?? "pi",
|
piBin: env.PI_BIN ?? "pi",
|
||||||
authMode: authMode as AppConfig["authMode"],
|
authMode,
|
||||||
|
authConfigFile,
|
||||||
|
authStateRoot,
|
||||||
|
authentication,
|
||||||
publicExposure,
|
publicExposure,
|
||||||
sessionStorage,
|
sessionStorage,
|
||||||
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
|
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 { 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";
|
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", () => {
|
test("loadConfig accepts container listening and runtime paths", () => {
|
||||||
expect(loadConfig({
|
expect(loadConfig({
|
||||||
HOST: "0.0.0.0",
|
HOST: "0.0.0.0",
|
||||||
@@ -41,10 +64,53 @@ test("loadConfig keeps local development defaults", () => {
|
|||||||
internalEmbeddingUrl: "http://embedding:11434",
|
internalEmbeddingUrl: "http://embedding:11434",
|
||||||
internalEmbeddingModel: "qwen3-embedding:0.6b",
|
internalEmbeddingModel: "qwen3-embedding:0.6b",
|
||||||
internalEmbeddingDimensions: 1024,
|
internalEmbeddingDimensions: 1024,
|
||||||
|
authMode: "none",
|
||||||
|
authConfigFile: "/run/thothii-auth/auth.yaml",
|
||||||
|
authStateRoot: "/data/auth",
|
||||||
});
|
});
|
||||||
expect(loadConfig({}).dataRoot).toBeUndefined();
|
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", () => {
|
test("loadConfig accepts only the allowed internal semantic runtime hosts", () => {
|
||||||
expect(loadConfig({
|
expect(loadConfig({
|
||||||
THT_INTERNAL_QDRANT_URL: "http://localhost:6333",
|
THT_INTERNAL_QDRANT_URL: "http://localhost:6333",
|
||||||
|
|||||||
Reference in New Issue
Block a user