feat(auth): define strict installation authentication config
This commit is contained in:
@@ -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