import type { AuthDiagnostic, GroupCatalog } from "./group-catalog.js"; import { isUsableAuthenticationSecret } from "./secret-policy.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 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)) { cancelResponse(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.results.length > 2 || !record.pagination || typeof record.pagination !== "object" || Array.isArray(record.pagination)) return "unreachable"; if (!Object.prototype.hasOwnProperty.call(record.pagination, "next")) return "unreachable"; const next = (record.pagination as { next: unknown }).next; if (next !== null) { if (typeof next !== "string" || next.length === 0 || next.length > 2048 || /\p{Cc}/u.test(next)) return "unreachable"; try { const continuation = new URL(next); if (continuation.protocol !== "https:" || continuation.username || continuation.password || continuation.hash) return "unreachable"; } catch { return "unreachable"; } return "ambiguous"; } const resultNames: string[] = []; for (const result of record.results) { if (!result || typeof result !== "object" || Array.isArray(result) || typeof (result as { name?: unknown }).name !== "string") return "unreachable"; resultNames.push((result as { name: string }).name); } const exactMatches = resultNames.filter((candidate) => candidate === name).length; if (exactMatches === 0) return "missing"; return exactMatches === 1 ? "present" : "ambiguous"; } 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 && isUsableAuthenticationSecret("THT_AUTHENTIK_API_TOKEN", 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; }, }; }