From f0ae680671d6c6c644ee5bcdc5d068b31a9eb9ec Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 17 Aug 2026 10:44:56 +0200 Subject: [PATCH] feat(auth): validate mapped groups through Authentik --- backend/src/auth/authentik-group-catalog.ts | 220 +++++++++++++++++++ backend/src/auth/diagnostics.ts | 147 +++++++++++++ backend/src/auth/group-catalog.ts | 36 +++ backend/src/auth/oidc-client.ts | 34 ++- backend/src/config/secret-bundle.ts | 1 + backend/test/auth-diagnostics.test.ts | 102 +++++++++ backend/test/authentik-group-catalog.test.ts | 115 ++++++++++ backend/test/oidc-client.test.ts | 8 +- backend/test/secret-bundle.test.ts | 8 + 9 files changed, 669 insertions(+), 2 deletions(-) create mode 100644 backend/src/auth/authentik-group-catalog.ts create mode 100644 backend/src/auth/diagnostics.ts create mode 100644 backend/src/auth/group-catalog.ts create mode 100644 backend/test/auth-diagnostics.test.ts create mode 100644 backend/test/authentik-group-catalog.test.ts diff --git a/backend/src/auth/authentik-group-catalog.ts b/backend/src/auth/authentik-group-catalog.ts new file mode 100644 index 00000000..6fab2294 --- /dev/null +++ b/backend/src/auth/authentik-group-catalog.ts @@ -0,0 +1,220 @@ +import type { AuthDiagnostic, GroupCatalog } from "./group-catalog.js"; +import { parseConfiguredTransportUrl } from "./url-policy.js"; + +const MAX_RESPONSE_BYTES = 1024 * 1024; +const REQUEST_TIMEOUT_MS = 5_000; + +export interface AuthentikGroupCatalogOptions { + baseUrl: string; + apiToken: string; + fetch?: typeof globalThis.fetch; +} + +function diagnostic( + code: AuthDiagnostic["code"], + message: string, + field?: string, +): AuthDiagnostic { + return { level: "error", code, message, ...(field === undefined ? {} : { field }) }; +} + +function catalogUnreachable(): AuthDiagnostic { + return diagnostic("oidc_group_catalog_unreachable", "The configured group catalog is unavailable."); +} + +function catalogUnauthorized(): AuthDiagnostic { + return diagnostic("oidc_group_catalog_unauthorized", "The configured group catalog credentials were rejected."); +} + +function missing(name: string): AuthDiagnostic { + return diagnostic("oidc_mapped_group_missing", "A configured authorization group does not exist.", name); +} + +function ambiguous(name: string): AuthDiagnostic { + return diagnostic("oidc_mapped_group_ambiguous", "A configured authorization group is ambiguous.", name); +} + +function safeApiToken(value: string): boolean { + return typeof value === "string" && value.length > 0 && value.length <= 16 * 1024 && !/\p{Cc}/u.test(value); +} + +function stableCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException("The operation was aborted", "AbortError"); +} + +function cancelResponse(response: Response): void { + try { + const cancelled = response.body?.cancel(); + if (cancelled) void cancelled.catch(() => undefined); + } catch { /* cancellation is advisory and never changes the diagnostic */ } +} + +function cancelReader(reader: ReadableStreamDefaultReader): void { + try { + const cancelled = reader.cancel(); + void cancelled.catch(() => undefined); + } catch { /* cancellation is advisory and never changes the diagnostic */ } +} + +function awaitWithAbort( + operation: Promise, + signal: AbortSignal, + onLateResolution?: (value: T) => void, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const abort = () => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", abort); + reject(abortReason(signal)); + }; + if (signal.aborted) { + abort(); + return; + } + signal.addEventListener("abort", abort, { once: true }); + operation.then( + (value) => { + if (settled) { + try { onLateResolution?.(value); } catch { /* best-effort cleanup only */ } + return; + } + settled = true; + signal.removeEventListener("abort", abort); + resolve(value); + }, + (error: unknown) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", abort); + reject(error); + }, + ); + }); +} + +function validContentLength(response: Response): boolean { + const value = response.headers.get("content-length"); + if (value === null) return true; + if (!/^\d+$/.test(value)) return false; + const length = Number(value); + return Number.isSafeInteger(length) && length <= MAX_RESPONSE_BYTES; +} + +async function readBounded(response: Response, signal: AbortSignal): Promise { + if (!validContentLength(response)) return undefined; + const reader = response.body?.getReader(); + if (!reader) return new Uint8Array(); + const chunks: Uint8Array[] = []; + let size = 0; + let complete = false; + try { + while (true) { + const { done, value } = await awaitWithAbort(reader.read(), signal); + if (done) break; + if (value.byteLength > MAX_RESPONSE_BYTES - size) return undefined; + chunks.push(value); + size += value.byteLength; + } + complete = true; + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; + } finally { + if (!complete) cancelReader(reader); + try { reader.releaseLock(); } catch { /* reader may already be unusable */ } + } +} + +type GroupResult = "present" | "missing" | "ambiguous" | "unauthorized" | "unreachable"; + +function exactResult(name: string, parsed: unknown): GroupResult { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "unreachable"; + const record = parsed as { results?: unknown; pagination?: unknown }; + if (!Array.isArray(record.results) || !record.pagination || typeof record.pagination !== "object" + || Array.isArray(record.pagination)) return "unreachable"; + const next = (record.pagination as { next?: unknown }).next; + if (next !== null && next !== undefined) return "ambiguous"; + if (record.results.length === 0) return "missing"; + if (record.results.length !== 1) return "ambiguous"; + const result = record.results[0]; + if (!result || typeof result !== "object" || Array.isArray(result) + || (result as { name?: unknown }).name !== name) return "missing"; + return "present"; +} + +export function createAuthentikGroupCatalog(options: AuthentikGroupCatalogOptions): GroupCatalog { + const origin = parseConfiguredTransportUrl(options.baseUrl, { allowLoopbackHttp: false, originOnly: true }); + const fetchImplementation = options.fetch ?? globalThis.fetch; + const valid = origin !== undefined && safeApiToken(options.apiToken) && typeof fetchImplementation === "function"; + + async function verify(name: string, signal: AbortSignal): Promise { + if (!origin || !valid || signal.aborted) return "unreachable"; + const target = new URL("/api/v3/core/groups/", origin); + target.searchParams.set("name", name); + target.searchParams.set("include_users", "false"); + target.searchParams.set("page_size", "2"); + const timeout = new AbortController(); + const timer = setTimeout(() => timeout.abort(), REQUEST_TIMEOUT_MS); + timer.unref(); + const requestSignal = AbortSignal.any([signal, timeout.signal]); + try { + const response = await awaitWithAbort( + Promise.resolve().then(() => fetchImplementation(target, { + headers: { accept: "application/json", authorization: `Bearer ${options.apiToken}` }, + redirect: "error", + signal: requestSignal, + })), + requestSignal, + cancelResponse, + ); + if (response.redirected || response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) { + cancelResponse(response); + return "unreachable"; + } + if (response.status === 401 || response.status === 403) { + cancelResponse(response); + return "unauthorized"; + } + if (!response.ok) { + cancelResponse(response); + return "unreachable"; + } + const body = await readBounded(response, requestSignal); + if (body === undefined) return "unreachable"; + try { + return exactResult(name, JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body))); + } catch { + return "unreachable"; + } + } catch { + return "unreachable"; + } finally { + clearTimeout(timer); + } + } + + return { + async verifyConfiguredGroups(names, signal) { + const diagnostics: AuthDiagnostic[] = []; + for (const name of [...new Set(names)].sort(stableCompare)) { + const outcome = await verify(name, signal); + if (outcome === "present") continue; + if (outcome === "missing") diagnostics.push(missing(name)); + else if (outcome === "ambiguous") diagnostics.push(ambiguous(name)); + else if (outcome === "unauthorized") return [catalogUnauthorized()]; + else return [catalogUnreachable()]; + } + return diagnostics; + }, + }; +} diff --git a/backend/src/auth/diagnostics.ts b/backend/src/auth/diagnostics.ts new file mode 100644 index 00000000..df6b3ec9 --- /dev/null +++ b/backend/src/auth/diagnostics.ts @@ -0,0 +1,147 @@ +import type { AuthenticationConfigProvider, AuthMode } from "./types.js"; +import type { LocalUserRegistry } from "./local-registry.js"; +import { OidcJwksUnavailableError, type OidcProtocol } from "./oidc-client.js"; +import type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics, GroupCatalog } from "./group-catalog.js"; + +export type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics } from "./group-catalog.js"; + +export interface AuthDiagnoser { + inspect(options: { live: boolean; interactive?: boolean; signal?: AbortSignal }): Promise; +} + +export interface AuthDiagnoserDependencies { + authMode: AuthMode; + authStateRoot: string; + authentication?: AuthenticationConfigProvider; + secrets?: ReadonlyMap; + localUserRegistry?: LocalUserRegistry; + /** Enables a host integration to inspect a local registry without exposing user records. */ + hasEnabledLocalAdmin?: () => Promise; + oidcProtocol?: OidcProtocol; + groupCatalog?: GroupCatalog; +} + +function check(code: AuthDiagnosticCode, message: string, field?: string): AuthDiagnostic { + return { level: "error", code, message, ...(field === undefined ? {} : { field }) }; +} + +function sessionRootIsSafe(value: string): boolean { + return typeof value === "string" && value.startsWith("/") && value.trim() === value + && value.length > 1 && !value.includes("\0") && !/\p{Cc}/u.test(value); +} + +function secretPresent(secrets: ReadonlyMap | undefined, name: string): boolean { + const value = secrets?.get(name); + return typeof value === "string" && value.length > 0 && value.length <= 16 * 1024 && !/\p{Cc}/u.test(value); +} + +function ordered(checks: readonly AuthDiagnostic[]): readonly AuthDiagnostic[] { + const unique = new Map(); + for (const item of checks) unique.set(`${item.code}\u0000${item.field ?? ""}`, item); + return [...unique.values()].sort((left, right) => { + const leftKey = `${left.code}\u0000${left.field ?? ""}`; + const rightKey = `${right.code}\u0000${right.field ?? ""}`; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); +} + +function stableCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +async function localRegistryIsUsable(deps: AuthDiagnoserDependencies): Promise { + try { + if (deps.hasEnabledLocalAdmin) { + if (!await deps.hasEnabledLocalAdmin()) return check("local_admin_missing", "No enabled local administrator is configured."); + return undefined; + } + if (!deps.localUserRegistry) return check("local_user_registry_invalid", "The local user registry is unavailable."); + // The registry's safe parser refuses to load without an enabled admin; this probe never exposes users. + await deps.localUserRegistry.findByUsername("diagnostic-probe"); + return undefined; + } catch { + return check("local_user_registry_invalid", "The local user registry is invalid."); + } +} + +export function createAuthDiagnoser(deps: AuthDiagnoserDependencies): AuthDiagnoser { + return { + async inspect(options): Promise { + const checks: AuthDiagnostic[] = []; + const signal = options.signal ?? new AbortController().signal; + if (!sessionRootIsSafe(deps.authStateRoot)) checks.push(check("auth_session_store_invalid", "The authentication session store is invalid.")); + + if (deps.authMode === "none" || deps.authMode === "mock") { + const result = ordered(checks); + return result.length === 0 + ? { ready: true, mode: deps.authMode, checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }] } + : { ready: false, mode: deps.authMode, checks: result }; + } + if (deps.authMode === "upstream") { + checks.push(check("auth_config_incomplete", "The deprecated upstream authentication mode is not certifiable.")); + return { ready: false, mode: deps.authMode, checks: ordered(checks) }; + } + + let loaded; + try { + if (!deps.authentication) throw new Error("missing authentication configuration"); + loaded = deps.authentication.current(); + } catch { + checks.push(check(deps.authentication ? "auth_config_invalid" : "auth_config_incomplete", "Authentication configuration is unavailable.")); + return { ready: false, mode: deps.authMode, checks: ordered(checks) }; + } + if (loaded.value.mode !== deps.authMode) { + checks.push(check("auth_config_invalid", "Authentication mode does not match its configuration.")); + return { ready: false, mode: deps.authMode, checks: ordered(checks) }; + } + + if (loaded.value.mode === "local") { + const local = await localRegistryIsUsable(deps); + if (local) checks.push(local); + const result = ordered(checks); + return result.length === 0 + ? { ready: true, mode: "local", checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }] } + : { ready: false, mode: "local", checks: result }; + } + + const requiredSecrets = ["THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN"]; + if (requiredSecrets.some((name) => !secretPresent(deps.secrets, name))) { + checks.push(check("oidc_secret_missing", "A required OIDC or group catalog secret is unavailable.")); + } + if (!options.live || checks.length > 0) { + const result = ordered(checks); + return result.length === 0 + ? { ready: true, mode: "oidc", checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }] } + : { ready: false, mode: "oidc", checks: result }; + } + + if (!deps.oidcProtocol) { + checks.push(check("oidc_discovery_unreachable", "The OIDC provider is unavailable.")); + } else { + try { + await deps.oidcProtocol.diagnose(signal); + } catch (error) { + checks.push(check( + error instanceof OidcJwksUnavailableError ? "oidc_jwks_unreachable" : "oidc_discovery_unreachable", + "The OIDC provider could not be validated.", + )); + } + } + if (!deps.groupCatalog) { + checks.push(check("oidc_group_catalog_unreachable", "The configured group catalog cannot be certified.")); + } else { + try { + checks.push(...await deps.groupCatalog.verifyConfiguredGroups( + Object.keys(loaded.value.authorization.groupRoles).sort(stableCompare), signal, + )); + } catch { + checks.push(check("oidc_group_catalog_unreachable", "The configured group catalog is unavailable.")); + } + } + const result = ordered(checks); + return result.length === 0 + ? { ready: true, mode: "oidc", checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }] } + : { ready: false, mode: "oidc", checks: result }; + }, + }; +} diff --git a/backend/src/auth/group-catalog.ts b/backend/src/auth/group-catalog.ts new file mode 100644 index 00000000..281bf66d --- /dev/null +++ b/backend/src/auth/group-catalog.ts @@ -0,0 +1,36 @@ +/** The fixed machine contract shared by the Authentik catalog and auth diagnostics. */ +export type AuthDiagnosticCode = + | "auth_ready" + | "auth_config_incomplete" + | "auth_config_invalid" + | "auth_session_store_invalid" + | "local_user_registry_invalid" + | "local_admin_missing" + | "oidc_secret_missing" + | "oidc_discovery_unreachable" + | "oidc_issuer_mismatch" + | "oidc_jwks_unreachable" + | "oidc_group_catalog_unreachable" + | "oidc_group_catalog_unauthorized" + | "oidc_mapped_group_missing" + | "oidc_mapped_group_ambiguous" + | "oidc_groups_claim_invalid" + | "oidc_device_flow_unavailable"; + +export interface AuthDiagnostic { + level: "error" | "info"; + code: AuthDiagnosticCode; + message: string; + field?: string; +} + +export interface AuthDiagnostics { + ready: boolean; + mode: "local" | "oidc" | "upstream" | "none" | "mock"; + checks: readonly AuthDiagnostic[]; +} + +/** A provider-specific proof that only the configured authorization groups exist. */ +export interface GroupCatalog { + verifyConfiguredGroups(names: readonly string[], signal: AbortSignal): Promise; +} diff --git a/backend/src/auth/oidc-client.ts b/backend/src/auth/oidc-client.ts index b331efc2..de87f04b 100644 --- a/backend/src/auth/oidc-client.ts +++ b/backend/src/auth/oidc-client.ts @@ -40,6 +40,14 @@ export class OidcProviderUnavailableError extends OidcProtocolError { } } +/** The discovery document resolved, but its signed-token key set could not be certified. */ +export class OidcJwksUnavailableError extends OidcProtocolError { + constructor() { + super("oidc_jwks_unreachable"); + this.name = "OidcJwksUnavailableError"; + } +} + export interface OidcProtocolOptions { issuer: string; clientId: string; @@ -419,6 +427,24 @@ async function verifyIdTokenSignature( } } +async function verifyJwksAvailability( + config: Configuration, + transport: BoundedOidcTransport, + jwksTimeoutMs: number, + signal: AbortSignal, +): Promise { + const metadata = config.serverMetadata(); + if (!text(metadata.jwks_uri, 2048)) throw new OidcProtocolError(); + const response = await transport.request( + httpsEndpoint(metadata.jwks_uri), + { headers: { accept: "application/json" }, redirect: "manual", signal }, + { timeoutMs: jwksTimeoutMs, requireSuccess: true }, + ); + const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await response.arrayBuffer())); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) + || !Array.isArray((parsed as { keys?: unknown }).keys)) throw new OidcProtocolError(); +} + export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol { const issuerUrl = configuredHttpsUrl(options.issuer); const callbackUrl = configuredCallbackUrl(options.callbackUrl); @@ -498,7 +524,13 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol { }, async diagnose(signal) { signal.throwIfAborted(); - await configuration(); + const config = await configuration(); + signal.throwIfAborted(); + try { + await verifyJwksAvailability(config, transport, jwksTimeoutMs, signal); + } catch { + throw new OidcJwksUnavailableError(); + } signal.throwIfAborted(); }, }; diff --git a/backend/src/config/secret-bundle.ts b/backend/src/config/secret-bundle.ts index b46746a3..960ac0a1 100644 --- a/backend/src/config/secret-bundle.ts +++ b/backend/src/config/secret-bundle.ts @@ -8,6 +8,7 @@ export const SECRET_BUNDLE_KEYS = Object.freeze([ "THT_MODEL_API_KEY", "THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY", "THT_CA", "THT_SSL_CA", "THT_VECTOR_BOOTSTRAP_PASSWORD", "THT_VECTOR_MIGRATOR_PASSWORD", "THT_VECTOR_READER_PASSWORD", "THT_VECTOR_WRITER_PASSWORD", "PI_PROVIDER_API_KEY", + "THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN", ] as const); const ALLOWED = new Set(SECRET_BUNDLE_KEYS); diff --git a/backend/test/auth-diagnostics.test.ts b/backend/test/auth-diagnostics.test.ts new file mode 100644 index 00000000..45278e2e --- /dev/null +++ b/backend/test/auth-diagnostics.test.ts @@ -0,0 +1,102 @@ +import { expect, test, vi } from "vitest"; +import { createAuthDiagnoser } from "../src/auth/diagnostics.js"; +import { OidcJwksUnavailableError } from "../src/auth/oidc-client.js"; +import type { LoadedAuthConfig } from "../src/auth/types.js"; + +const sentinels = [ + "oidc-client-secret-UNIQUE-7P3", "authentik-api-token-UNIQUE-9Q7", + "cookie-UNIQUE-5M1", "$argon2id$v=19$password-hash-UNIQUE-2T8", "/private/path-UNIQUE-4K6", +]; + +function oidcConfig(): LoadedAuthConfig { + return { + revision: "a".repeat(64), sourcePath: "/safe/auth.yaml", + value: { + version: 1, mode: "oidc", publicUrl: "https://thothii.example.test", + session: { regularTtlSeconds: 1, regularIdleSeconds: 1, rememberTtlSeconds: 1, rememberIdleSeconds: 1, oidcTtlSeconds: 1 }, + oidc: { issuer: "https://issuer.example.test/application/o/thothii/", clientId: "thothii", clientSecretRef: "THT_OIDC_CLIENT_SECRET", scopes: ["openid"], groupsClaim: "groups" }, + groupCatalog: { driver: "authentik", baseUrl: "https://authentik.example.test", apiTokenRef: "THT_AUTHENTIK_API_TOKEN" }, + authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } }, + }, + }; +} + +test("reports deterministic live OIDC checks and silently ignores unrelated groups", async () => { + const oidcDiagnose = vi.fn(async () => undefined); + const groupCatalog = { verifyConfiguredGroups: vi.fn(async (names: readonly string[]) => { + expect(names).toEqual(["TOT Admin", "TOT Users"]); + return []; + }) }; + const report = await createAuthDiagnoser({ + authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state", + secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]), + oidcProtocol: { diagnose: oidcDiagnose }, groupCatalog, + }).inspect({ live: true }); + + expect(report).toEqual({ ready: true, mode: "oidc", checks: [expect.objectContaining({ level: "info", code: "auth_ready" })] }); + expect(oidcDiagnose).toHaveBeenCalledOnce(); + expect(groupCatalog.verifyConfiguredGroups).toHaveBeenCalledOnce(); + expect(report.checks).not.toContainEqual(expect.objectContaining({ level: "warning" })); + expect(JSON.stringify(report)).not.toContain("Unmapped Corporate Group"); +}); + +test("requires both fixed OIDC and Authentik secrets only in OIDC mode", async () => { + const oidc = createAuthDiagnoser({ + authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state", secrets: new Map(), + }); + const local = createAuthDiagnoser({ + authMode: "local", authStateRoot: "/safe/auth-state", secrets: new Map(), + authentication: { current: () => ({ + revision: "b".repeat(64), sourcePath: "/safe/auth.yaml", + value: { + version: 1, mode: "local", publicUrl: "http://127.0.0.1:8080", + session: { regularTtlSeconds: 1, regularIdleSeconds: 1, rememberTtlSeconds: 1, rememberIdleSeconds: 1, oidcTtlSeconds: 1 }, + local: { usersFile: "users.yaml" }, + }, + }) }, + hasEnabledLocalAdmin: async () => false, + }); + + await expect(oidc.inspect({ live: false })).resolves.toMatchObject({ ready: false, checks: [ + expect.objectContaining({ code: "oidc_secret_missing" }), + ] }); + await expect(local.inspect({ live: false })).resolves.toMatchObject({ ready: false, checks: [ + expect.objectContaining({ code: "local_admin_missing" }), + ] }); +}); + +test("maps OIDC and group catalog failures to only the closed diagnostics code union", async () => { + const report = await createAuthDiagnoser({ + authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state", + secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]), + oidcProtocol: { diagnose: async () => { throw new Error("issuer unavailable"); } }, + groupCatalog: { verifyConfiguredGroups: async () => [{ level: "error", code: "oidc_mapped_group_missing", message: "configured group is missing", field: "TOT Users" }] }, + }).inspect({ live: true }); + + expect(report.ready).toBe(false); + expect(report.checks.map((check) => check.code)).toEqual(["oidc_discovery_unreachable", "oidc_mapped_group_missing"]); +}); + +test("reports a JWKS validation failure through its closed diagnostic code", async () => { + const report = await createAuthDiagnoser({ + authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state", + secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]), + oidcProtocol: { diagnose: async () => { throw new OidcJwksUnavailableError(); } }, + groupCatalog: { verifyConfiguredGroups: async () => [] }, + }).inspect({ live: true }); + + expect(report.checks).toEqual([expect.objectContaining({ code: "oidc_jwks_unreachable" })]); +}); + +test("redacts exceptional configuration, registry, protocol, and catalog errors", async () => { + const detail = sentinels.join(" "); + const report = await createAuthDiagnoser({ + authMode: "oidc", authentication: { current: () => { throw new Error(detail); } }, authStateRoot: sentinels[4]!, + secrets: new Map(), oidcProtocol: { diagnose: async () => { throw new Error(detail); } }, + groupCatalog: { verifyConfiguredGroups: async () => { throw new Error(detail); } }, + }).inspect({ live: true }); + + const rendered = JSON.stringify(report); + for (const sentinel of sentinels) expect(rendered).not.toContain(sentinel); + expect(report.checks.every((check) => check.level === "error" || check.level === "info")).toBe(true); +}); diff --git a/backend/test/authentik-group-catalog.test.ts b/backend/test/authentik-group-catalog.test.ts new file mode 100644 index 00000000..0bb5ccac --- /dev/null +++ b/backend/test/authentik-group-catalog.test.ts @@ -0,0 +1,115 @@ +import { expect, test, vi } from "vitest"; +import { createAuthentikGroupCatalog } from "../src/auth/authentik-group-catalog.js"; + +const apiToken = "authentik-api-token-UNIQUE-9Q7"; +const clientSecret = "oidc-client-secret-UNIQUE-7P3"; +const passwordHash = "$argon2id$v=19$password-hash-UNIQUE-2T8"; +const cookie = "cookie-UNIQUE-5M1"; +const filePath = "/private/path-UNIQUE-4K6"; + +function catalog(fetch: typeof globalThis.fetch) { + return createAuthentikGroupCatalog({ + baseUrl: "https://authentik.example.test", + apiToken, + fetch, + }); +} + +function groups(...names: string[]): Response { + return Response.json({ pagination: { next: null }, results: names.map((name) => ({ name })) }); +} + +test("looks up only each configured group by its exact encoded name", async () => { + const fetch = vi.fn(async () => groups("TOT Users")); + const result = await catalog(fetch).verifyConfiguredGroups(["TOT Users"], new AbortController().signal); + + expect(result).toEqual([]); + expect(fetch).toHaveBeenCalledOnce(); + const [input, init] = fetch.mock.calls[0]!; + expect(String(input)).toBe("https://authentik.example.test/api/v3/core/groups/?name=TOT+Users&include_users=false&page_size=2"); + expect(init).toMatchObject({ redirect: "error" }); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${apiToken}`); + expect(init?.signal).toBeInstanceOf(AbortSignal); +}); + +test.each([ + ["missing", groups(), "oidc_mapped_group_missing"], + ["duplicate exact results", groups("TOT Users", "TOT Users"), "oidc_mapped_group_ambiguous"], + ["non-exact result", groups("tot users"), "oidc_mapped_group_missing"], +])("reports configured group %s without exposing an upstream body", async (_caseName, response, code) => { + const result = await catalog(vi.fn(async () => response)) + .verifyConfiguredGroups(["TOT Users"], new AbortController().signal); + + expect(result).toEqual([expect.objectContaining({ level: "error", code, field: "TOT Users" })]); +}); + +test("treats a pagination continuation as an ambiguous configured group", async () => { + const response = Response.json({ pagination: { next: "https://authentik.example.test/api/v3/core/groups/?page=2" }, results: [{ name: "TOT Users" }] }); + const result = await catalog(vi.fn(async () => response)) + .verifyConfiguredGroups(["TOT Users"], new AbortController().signal); + + expect(result).toEqual([expect.objectContaining({ code: "oidc_mapped_group_ambiguous", field: "TOT Users" })]); +}); + +test.each([ + ["unauthorized", new Response("upstream body must not escape", { status: 401 }), "oidc_group_catalog_unauthorized"], + ["forbidden", new Response("upstream body must not escape", { status: 403 }), "oidc_group_catalog_unauthorized"], + ["redirect", new Response(null, { status: 302, headers: { location: "https://elsewhere.invalid" } }), "oidc_group_catalog_unreachable"], + ["invalid json", new Response("not-json"), "oidc_group_catalog_unreachable"], +])("returns a stable diagnostic for %s without parsing or leaking response data", async (_caseName, response, code) => { + const result = await catalog(vi.fn(async () => response)) + .verifyConfiguredGroups(["TOT Users"], new AbortController().signal); + + expect(result).toEqual([expect.objectContaining({ level: "error", code })]); + expect(JSON.stringify(result)).not.toContain("upstream body must not escape"); +}); + +test("refuses declared and streamed group catalog bodies larger than one MiB", async () => { + const declared = new Response(new ReadableStream({ pull() { throw new Error("must not read"); } }), { + headers: { "content-length": String(1024 * 1024 + 1) }, + }); + const streamed = new Response(new ReadableStream({ + type: "bytes", + pull(controller) { + controller.enqueue(new Uint8Array(1024 * 1024)); + controller.enqueue(new Uint8Array(1)); + controller.close(); + }, + })); + + for (const response of [declared, streamed]) { + const result = await catalog(vi.fn(async () => response)) + .verifyConfiguredGroups(["TOT Users"], new AbortController().signal); + expect(result).toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]); + } +}); + +test("aborts a hanging request at five seconds", async () => { + vi.useFakeTimers(); + try { + let aborted = false; + const fetch = vi.fn((_input, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + aborted = true; + reject(new DOMException("aborted", "AbortError")); + }, { once: true }); + })); + const completion = catalog(fetch).verifyConfiguredGroups(["TOT Users"], new AbortController().signal); + await vi.advanceTimersByTimeAsync(5_000); + + await expect(completion).resolves.toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]); + expect(aborted).toBe(true); + } finally { + vi.useRealTimers(); + } +}); + +test("never puts secrets or upstream details in catalog diagnostics", async () => { + const upstreamDetail = `${apiToken} ${clientSecret} ${passwordHash} ${cookie} ${filePath}`; + const result = await catalog(vi.fn(async () => { + throw new Error(upstreamDetail); + })).verifyConfiguredGroups(["TOT Users"], new AbortController().signal); + + const rendered = JSON.stringify(result); + for (const sentinel of [apiToken, clientSecret, passwordHash, cookie, filePath]) expect(rendered).not.toContain(sentinel); +}); diff --git a/backend/test/oidc-client.test.ts b/backend/test/oidc-client.test.ts index 4fbd0a03..88580a2a 100644 --- a/backend/test/oidc-client.test.ts +++ b/backend/test/oidc-client.test.ts @@ -1,6 +1,6 @@ import { createSign, generateKeyPairSync } from "node:crypto"; import { expect, test } from "vitest"; -import { createOidcProtocol, OidcProtocolError, OidcProviderUnavailableError } from "../src/auth/oidc-client.js"; +import { createOidcProtocol, OidcJwksUnavailableError, OidcProtocolError, OidcProviderUnavailableError } from "../src/auth/oidc-client.js"; const issuer = "https://issuer.example.test"; const clientId = "thothii"; @@ -362,6 +362,12 @@ test("aborts a hanging JWKS request at the configured timeout", async () => { expect(aborted).toBe(true); }); +test("diagnose validates the bounded JWKS endpoint after discovery", async () => { + const subject = protocol({ jwksResponse: () => new Response("upstream JWKS body", { status: 503 }) }); + + await expect(subject.diagnose(new AbortController().signal)).rejects.toBeInstanceOf(OidcJwksUnavailableError); +}); + test("rejects an oversized JWKS Content-Length before reading the body", async () => { let pulls = 0; let cancelled = false; diff --git a/backend/test/secret-bundle.test.ts b/backend/test/secret-bundle.test.ts index a3f2b550..dc09b20e 100644 --- a/backend/test/secret-bundle.test.ts +++ b/backend/test/secret-bundle.test.ts @@ -22,6 +22,14 @@ test("parses comments, blank lines and values containing equals", () => { ])); }); +test("accepts the fixed OIDC and Authentik secret references", () => { + const file = bundle("THT_OIDC_CLIENT_SECRET=oidc-secret\nTHT_AUTHENTIK_API_TOKEN=authentik-token\n"); + expect(loadSecretBundle(file)).toEqual(new Map([ + ["THT_OIDC_CLIENT_SECRET", "oidc-secret"], + ["THT_AUTHENTIK_API_TOKEN", "authentik-token"], + ])); +}); + test.each([ ["duplicate", "THT_MODEL_API_KEY=a\nTHT_MODEL_API_KEY=b\n"], ["unknown", "UNKNOWN_KEY=x\n"],