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
+102
View File
@@ -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);
});
+7 -1
View File
@@ -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;
+8
View File
@@ -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"],