fix(auth): make diagnostics bounded and portable

This commit is contained in:
2026-08-17 12:19:19 +02:00
parent 7cfbee36fa
commit be1724890a
23 changed files with 1088 additions and 116 deletions
+206 -2
View File
@@ -1,4 +1,4 @@
import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, 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";
@@ -109,6 +109,41 @@ test("requires both fixed OIDC and Authentik secrets only in OIDC mode", async (
] });
});
test.each([
["OIDC maximum", "THT_OIDC_CLIENT_SECRET", 4096, true],
["OIDC overflow", "THT_OIDC_CLIENT_SECRET", 4097, false],
["Authentik maximum", "THT_AUTHENTIK_API_TOKEN", 16 * 1024, true],
["Authentik overflow", "THT_AUTHENTIK_API_TOKEN", 16 * 1024 + 1, false],
])("uses the runtime $s secret boundary in static diagnosis", async (_label, name, length, ready) => {
const secrets = new Map<string, string>([
["THT_OIDC_CLIENT_SECRET", "o"],
["THT_AUTHENTIK_API_TOKEN", "a"],
]);
secrets.set(name, "x".repeat(length));
const report = await createAuthDiagnoser({
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
sessionRootValidator: acceptSessionRoot, secrets,
}).inspect({ live: false });
expect(report.ready).toBe(ready);
expect(report.checks.map((item) => item.code)).toEqual(ready ? ["auth_ready"] : ["oidc_secret_missing"]);
});
test("rejects control characters in either required secret during static diagnosis", async () => {
for (const name of ["THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN"] as const) {
const secrets = new Map<string, string>([
["THT_OIDC_CLIENT_SECRET", "oidc-secret"],
["THT_AUTHENTIK_API_TOKEN", "authentik-token"],
]);
secrets.set(name, "invalid\u0000secret");
const report = await createAuthDiagnoser({
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
sessionRootValidator: acceptSessionRoot, secrets,
}).inspect({ live: false });
expect(report.checks).toEqual([expect.objectContaining({ code: "oidc_secret_missing" })]);
}
});
test("distinguishes a valid registry without an enabled admin from a malformed registry", async () => {
const validRoot = privateRoot();
const validUsers = join(validRoot, "users.yaml");
@@ -177,6 +212,115 @@ test("reports a JWKS validation failure through its closed diagnostic code", asy
expect(report.checks).toEqual([expect.objectContaining({ code: "oidc_jwks_unreachable" })]);
});
test("bounds a live diagnosis whose OIDC dependency ignores abort and starts no later checks", async () => {
vi.useFakeTimers();
try {
let rejectLate: ((error: Error) => void) | undefined;
const oidcDiagnose = vi.fn(() => new Promise<void>((_resolve, reject) => { rejectLate = reject; }));
const verifyConfiguredGroups = vi.fn(async () => []);
const completion = createAuthDiagnoser({
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
sessionRootValidator: acceptSessionRoot,
secrets: new Map([["THT_OIDC_CLIENT_SECRET", "oidc"], ["THT_AUTHENTIK_API_TOKEN", "authentik"]]),
oidcProtocol: { diagnose: oidcDiagnose }, groupCatalog: { verifyConfiguredGroups },
}).inspect({ live: true });
const outcome = completion.then((report) => report, () => undefined);
await vi.advanceTimersByTimeAsync(29_999);
await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending");
await vi.advanceTimersByTimeAsync(1);
await expect(outcome).resolves.toMatchObject({
ready: false,
checks: [expect.objectContaining({ code: "oidc_discovery_unreachable" })],
});
expect(oidcDiagnose).toHaveBeenCalledOnce();
expect(verifyConfiguredGroups).not.toHaveBeenCalled();
rejectLate?.(new Error("late-secret-detail"));
await Promise.resolve();
} finally {
vi.useRealTimers();
}
});
test("composes caller cancellation with the overall live-diagnostic deadline", async () => {
const caller = new AbortController();
const verifyConfiguredGroups = vi.fn(async () => []);
const completion = createAuthDiagnoser({
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
sessionRootValidator: acceptSessionRoot,
secrets: new Map([["THT_OIDC_CLIENT_SECRET", "oidc"], ["THT_AUTHENTIK_API_TOKEN", "authentik"]]),
oidcProtocol: { diagnose: async () => await new Promise<void>(() => undefined) },
groupCatalog: { verifyConfiguredGroups },
}).inspect({ live: true, signal: caller.signal });
caller.abort();
await expect(completion).resolves.toMatchObject({
ready: false,
checks: [expect.objectContaining({ code: "oidc_discovery_unreachable" })],
});
expect(verifyConfiguredGroups).not.toHaveBeenCalled();
});
test("never reports auth_ready when cancellation lands during OIDC completion", async () => {
const caller = new AbortController();
const verifyConfiguredGroups = vi.fn(async () => []);
const report = await createAuthDiagnoser({
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state",
sessionRootValidator: acceptSessionRoot,
secrets: new Map([["THT_OIDC_CLIENT_SECRET", "oidc"], ["THT_AUTHENTIK_API_TOKEN", "authentik"]]),
oidcProtocol: { diagnose: async () => { caller.abort(); } },
groupCatalog: { verifyConfiguredGroups },
}).inspect({ live: true, signal: caller.signal });
expect(report).toMatchObject({
ready: false,
checks: [expect.objectContaining({ code: "oidc_discovery_unreachable" })],
});
expect(verifyConfiguredGroups).not.toHaveBeenCalled();
});
test("stops starting mapped-group requests when the overall live deadline expires", async () => {
vi.useFakeTimers();
try {
const loaded = oidcConfig();
if (loaded.value.mode !== "oidc") throw new Error("test configuration is not OIDC");
loaded.value.authorization.groupRoles = Object.fromEntries(Array.from({ length: 10 }, (_unused, index) => [
`Mapped Group ${String(index).padStart(2, "0")}`,
index === 0 ? ["admin"] : ["user"],
]));
const fetch = vi.fn<typeof globalThis.fetch>((input, init) => new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
const name = new URL(String(input)).searchParams.get("name");
resolve(Response.json({ pagination: { next: null }, results: [{ name }] }));
}, 4_000);
init?.signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("aborted", "AbortError"));
}, { once: true });
}));
const completion = createAuthDiagnoser({
authMode: "oidc", authentication: { current: () => loaded }, authStateRoot: "/safe/auth-state",
sessionRootValidator: acceptSessionRoot,
secrets: new Map([["THT_OIDC_CLIENT_SECRET", "oidc"], ["THT_AUTHENTIK_API_TOKEN", "authentik"]]),
oidcProtocol: { diagnose: async () => undefined },
groupCatalog: createAuthentikGroupCatalog({
baseUrl: "https://authentik.example.test", apiToken: "authentik", fetch,
}),
}).inspect({ live: true });
await vi.advanceTimersByTimeAsync(30_000);
await expect(completion).resolves.toMatchObject({
ready: false,
checks: [expect.objectContaining({ code: "oidc_group_catalog_unreachable" })],
});
expect(fetch).toHaveBeenCalledTimes(8);
await vi.advanceTimersByTimeAsync(30_000);
expect(fetch).toHaveBeenCalledTimes(8);
} finally {
vi.useRealTimers();
}
});
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) => {
@@ -211,6 +355,40 @@ test("maps a concrete discovery adapter issuer mismatch to its dedicated code",
expect(JSON.stringify(report)).not.toContain("different-issuer");
});
test.each([
["an upstream error", 503, {
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"],
}],
["schema-invalid metadata", 200, { issuer: "https://different-issuer.example.test" }],
])("does not classify %s containing an issuer as an issuer mismatch", async (_label, status, body) => {
const loaded = oidcConfig();
const fetch = vi.fn<typeof globalThis.fetch>(async () => Response.json(body, { status }));
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_discovery_unreachable" })]);
expect(report.checks).not.toContainEqual(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({
@@ -235,21 +413,47 @@ test.skipIf(process.platform === "win32")("uses the runtime validator for canoni
const linkedRoot = join(privateRoot(), "linked-auth");
symlinkSync(realRoot, linkedRoot);
const absent = join(privateRoot(), "absent-auth");
const absentParent = join(privateRoot(), "absent-parent");
const absentNested = join(absentParent, "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 missingReport = await createAuthDiagnoser({ authMode: "none", authStateRoot: absent }).inspect({ live: false });
expect(missingReport).toMatchObject({ ready: true, checks: [expect.objectContaining({ code: "auth_ready" })] });
expect(existsSync(absent)).toBe(false);
for (const unsafe of [traversal, linkedRoot, absentNested, 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);
}
expect(existsSync(absentParent)).toBe(false);
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("routes native Windows static session-root validation through the auth-storage bridge", async () => {
const originalPlatform = process.platform;
const validateRoot = vi.fn(async () => undefined);
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
try {
const report = await createAuthDiagnoser({
authMode: "none",
authStateRoot: "C:\\ProgramData\\ThothII\\auth",
windowsStorageBridge: { validateRoot } as never,
}).inspect({ live: false });
expect(report).toMatchObject({ ready: true });
expect(validateRoot).toHaveBeenCalledOnce();
expect(validateRoot).toHaveBeenCalledWith("C:\\ProgramData\\ThothII\\auth");
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
}
});
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`); });