feat(auth): validate mapped groups through Authentik

This commit is contained in:
2026-08-17 10:44:56 +02:00
parent 33ae7cfdc2
commit f0ae680671
9 changed files with 669 additions and 2 deletions
+220
View File
@@ -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;
},
};
}
+147
View File
@@ -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 };
},
};
}
+36
View File
@@ -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[]>;
}
+33 -1
View File
@@ -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();
},
};