feat(auth): validate mapped groups through Authentik
This commit is contained in:
@@ -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<Uint8Array>): void {
|
||||
try {
|
||||
const cancelled = reader.cancel();
|
||||
void cancelled.catch(() => undefined);
|
||||
} catch { /* cancellation is advisory and never changes the diagnostic */ }
|
||||
}
|
||||
|
||||
function awaitWithAbort<T>(
|
||||
operation: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
onLateResolution?: (value: T) => void,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((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<Uint8Array | undefined> {
|
||||
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<GroupResult> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<AuthDiagnostics>;
|
||||
}
|
||||
|
||||
export interface AuthDiagnoserDependencies {
|
||||
authMode: AuthMode;
|
||||
authStateRoot: string;
|
||||
authentication?: AuthenticationConfigProvider;
|
||||
secrets?: ReadonlyMap<string, string>;
|
||||
localUserRegistry?: LocalUserRegistry;
|
||||
/** Enables a host integration to inspect a local registry without exposing user records. */
|
||||
hasEnabledLocalAdmin?: () => Promise<boolean>;
|
||||
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<string, string> | 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<string, AuthDiagnostic>();
|
||||
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<AuthDiagnostic | undefined> {
|
||||
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<AuthDiagnostics> {
|
||||
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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<readonly AuthDiagnostic[]>;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<string>(SECRET_BUNDLE_KEYS);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(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<typeof globalThis.fetch>((_input, init) => new Promise<Response>((_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<typeof globalThis.fetch>(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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user