237 lines
8.3 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|