Files
ThothII/backend/src/auth/authentik-group-catalog.ts
T

237 lines
8.3 KiB
TypeScript

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<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)) {
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<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;
},
};
}