fix(auth): make diagnostics match runtime safety

This commit is contained in:
2026-08-17 11:23:52 +02:00
parent f0ae680671
commit 7cfbee36fa
14 changed files with 619 additions and 99 deletions
+177 -16
View File
@@ -1,12 +1,32 @@
import { expect, test, vi } from "vitest";
import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { afterEach, expect, test, vi } from "vitest";
import { createAuthDiagnoser } from "../src/auth/diagnostics.js";
import { OidcJwksUnavailableError } from "../src/auth/oidc-client.js";
import { createAuthenticationConfigProvider } from "../src/auth/config.js";
import { createAuthentikGroupCatalog } from "../src/auth/authentik-group-catalog.js";
import { createLocalUserRegistry } from "../src/auth/local-registry.js";
import { createOidcProtocol, 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",
];
const roots: string[] = [];
const acceptSessionRoot = () => undefined;
const validPasswordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function privateRoot(): string {
const root = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-diagnostics-"));
chmodSync(root, 0o700);
roots.push(root);
return root;
}
function oidcConfig(): LoadedAuthConfig {
return {
@@ -21,40 +41,64 @@ function oidcConfig(): LoadedAuthConfig {
};
}
function localConfig(sourcePath: string): LoadedAuthConfig {
return {
revision: "b".repeat(64), sourcePath,
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" },
},
};
}
function registryYaml(role: "user" | "admin", passwordHash = validPasswordHash): string {
return [
"version: 1", "users:", " - id: 6ba7b810-9dad-4ed1-80b4-00c04fd430c8",
" username: Admin", " displayName: Admin", ` passwordHash: ${passwordHash}`,
" roles:", ` - ${role}`, " enabled: true", " authRevision: 1", "",
].join("\n");
}
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 fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
const requested = new URL(String(input)).searchParams.get("name");
return Response.json({
pagination: { next: null },
results: [{ name: requested }, { name: "Unmapped Corporate Group" }],
});
});
const groupCatalog = createAuthentikGroupCatalog({
baseUrl: "https://authentik.example.test", apiToken: sentinels[1]!, fetch,
});
const report = await createAuthDiagnoser({
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
sessionRootValidator: acceptSessionRoot,
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(fetch).toHaveBeenCalledTimes(2);
expect(fetch.mock.calls.map(([input]) => new URL(String(input)).searchParams.get("name")))
.toEqual(["TOT Admin", "TOT Users"]);
expect(report.checks).not.toContainEqual(expect.objectContaining({ level: "warning" }));
expect(JSON.stringify(report)).not.toContain("Unmapped Corporate Group");
expect(report.checks.map((item) => item.message).join("\n")).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(),
sessionRootValidator: acceptSessionRoot,
});
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,
sessionRootValidator: acceptSessionRoot,
authentication: { current: () => localConfig("/safe/auth.yaml") },
localUserRegistry: { hasEnabledAdmin: async () => false } as never,
});
await expect(oidc.inspect({ live: false })).resolves.toMatchObject({ ready: false, checks: [
@@ -65,9 +109,53 @@ test("requires both fixed OIDC and Authentik secrets only in OIDC mode", async (
] });
});
test("distinguishes a valid registry without an enabled admin from a malformed registry", async () => {
const validRoot = privateRoot();
const validUsers = join(validRoot, "users.yaml");
writeFileSync(validUsers, registryYaml("user"), { encoding: "utf8", mode: 0o600 });
chmodSync(validUsers, 0o600);
const validReport = await createAuthDiagnoser({
authMode: "local", authStateRoot: validRoot,
authentication: { current: () => localConfig(join(validRoot, "auth.yaml")) },
localUserRegistry: createLocalUserRegistry(validUsers),
}).inspect({ live: false });
expect(validReport.checks).toEqual([expect.objectContaining({ code: "local_admin_missing" })]);
const malformedRoot = privateRoot();
const malformedUsers = join(malformedRoot, "users.yaml");
writeFileSync(malformedUsers, registryYaml("admin", sentinels[3]), { encoding: "utf8", mode: 0o600 });
chmodSync(malformedUsers, 0o600);
const malformedReport = await createAuthDiagnoser({
authMode: "local", authStateRoot: malformedRoot,
authentication: { current: () => localConfig(join(malformedRoot, "auth.yaml")) },
localUserRegistry: createLocalUserRegistry(malformedUsers),
}).inspect({ live: false });
expect(malformedReport.checks).toEqual([expect.objectContaining({ code: "local_user_registry_invalid" })]);
expect(JSON.stringify(malformedReport)).not.toContain(sentinels[3]);
expect(JSON.stringify(malformedReport)).not.toContain(malformedUsers);
});
test("maps unsafe auth.yaml storage from the real provider to a redacted config failure", async () => {
const root = privateRoot();
const unsafePath = join(root, basename(sentinels[4]!));
writeFileSync(unsafePath, [
"version: 1", "mode: local", "publicUrl: http://127.0.0.1:8080", "local:", " usersFile: users.yaml", "",
].join("\n"), { encoding: "utf8", mode: 0o640 });
chmodSync(unsafePath, 0o640);
const report = await createAuthDiagnoser({
authMode: "local", authStateRoot: root,
authentication: createAuthenticationConfigProvider(unsafePath),
}).inspect({ live: false });
expect(report.checks).toEqual([expect.objectContaining({ code: "auth_config_invalid" })]);
expect(JSON.stringify(report)).not.toContain(unsafePath);
expect(JSON.stringify(report)).not.toContain(sentinels[4]);
});
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",
sessionRootValidator: acceptSessionRoot,
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" }] },
@@ -80,6 +168,7 @@ test("maps OIDC and group catalog failures to only the closed diagnostics code u
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",
sessionRootValidator: acceptSessionRoot,
secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
oidcProtocol: { diagnose: async () => { throw new OidcJwksUnavailableError(); } },
groupCatalog: { verifyConfiguredGroups: async () => [] },
@@ -88,6 +177,40 @@ test("reports a JWKS validation failure through its closed diagnostic code", asy
expect(report.checks).toEqual([expect.objectContaining({ code: "oidc_jwks_unreachable" })]);
});
test("maps a concrete discovery adapter issuer mismatch to its dedicated code", async () => {
const loaded = oidcConfig();
const fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
const url = new URL(String(input));
if (!url.pathname.includes(".well-known")) throw new Error("JWKS must not be requested after issuer mismatch");
return Response.json({
issuer: "https://different-issuer.example.test",
authorization_endpoint: "https://issuer.example.test/authorize",
token_endpoint: "https://issuer.example.test/token",
jwks_uri: "https://issuer.example.test/jwks",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
});
});
const oidcProtocol = createOidcProtocol({
issuer: loaded.value.mode === "oidc" ? loaded.value.oidc.issuer : "",
clientId: "thothii", clientSecret: sentinels[0]!,
callbackUrl: "https://thothii.example.test/api/auth/oidc/callback",
scopes: ["openid"], groupsClaim: "groups", fetch,
});
const report = await createAuthDiagnoser({
authMode: "oidc", authentication: { current: () => loaded }, authStateRoot: "/safe/auth-state",
sessionRootValidator: acceptSessionRoot,
secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
oidcProtocol, groupCatalog: { verifyConfiguredGroups: async () => [] },
}).inspect({ live: true });
expect(report.checks).toEqual([expect.objectContaining({ code: "oidc_issuer_mismatch" })]);
expect(fetch).toHaveBeenCalledOnce();
expect(JSON.stringify(report)).not.toContain("different-issuer");
});
test("redacts exceptional configuration, registry, protocol, and catalog errors", async () => {
const detail = sentinels.join(" ");
const report = await createAuthDiagnoser({
@@ -100,3 +223,41 @@ test("redacts exceptional configuration, registry, protocol, and catalog errors"
for (const sentinel of sentinels) expect(rendered).not.toContain(sentinel);
expect(report.checks.every((check) => check.level === "error" || check.level === "info")).toBe(true);
});
test.skipIf(process.platform === "win32")("uses the runtime validator for canonical, private session roots", async () => {
const valid = privateRoot();
await expect(createAuthDiagnoser({ authMode: "none", authStateRoot: valid }).inspect({ live: false }))
.resolves.toMatchObject({ ready: true, checks: [expect.objectContaining({ code: "auth_ready" })] });
const realRoot = join(privateRoot(), "real-auth");
mkdirSync(realRoot, { mode: 0o700 });
chmodSync(realRoot, 0o700);
const linkedRoot = join(privateRoot(), "linked-auth");
symlinkSync(realRoot, linkedRoot);
const absent = join(privateRoot(), "absent-auth");
const blockedParent = join(privateRoot(), "not-a-directory");
writeFileSync(blockedParent, "blocked", { mode: 0o600 });
const traversal = `${valid}/../${basename(valid)}`;
for (const unsafe of [traversal, linkedRoot, absent, join(blockedParent, "auth")]) {
const report = await createAuthDiagnoser({ authMode: "none", authStateRoot: unsafe }).inspect({ live: false });
expect(report).toMatchObject({ ready: false, checks: [expect.objectContaining({ code: "auth_session_store_invalid" })] });
expect(JSON.stringify(report)).not.toContain(unsafe);
}
chmodSync(valid, 0o750);
await expect(createAuthDiagnoser({ authMode: "none", authStateRoot: valid }).inspect({ live: false }))
.resolves.toMatchObject({ ready: false, checks: [expect.objectContaining({ code: "auth_session_store_invalid" })] });
});
test("accepts a platform storage validator without exposing its root or failure", async () => {
const platformRoot = "C:\\private-path-UNIQUE-6R2\\auth";
const sessionRootValidator = vi.fn(async () => { throw new Error(`${platformRoot} denied`); });
const report = await createAuthDiagnoser({
authMode: "none", authStateRoot: platformRoot, sessionRootValidator,
}).inspect({ live: false });
expect(sessionRootValidator).toHaveBeenCalledWith(platformRoot);
expect(report.checks).toEqual([expect.objectContaining({ code: "auth_session_store_invalid" })]);
expect(JSON.stringify(report)).not.toContain(platformRoot);
});