555 lines
27 KiB
TypeScript
555 lines
27 KiB
TypeScript
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";
|
|
import { createAuthDiagnoser } from "../src/auth/diagnostics.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 { validateAuthSessionRoot } from "../src/auth/session-store.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 {
|
|
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"] } },
|
|
},
|
|
};
|
|
}
|
|
|
|
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 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(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(),
|
|
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: [
|
|
expect.objectContaining({ code: "oidc_secret_missing" }),
|
|
] });
|
|
await expect(local.inspect({ live: false })).resolves.toMatchObject({ ready: false, checks: [
|
|
expect.objectContaining({ code: "local_admin_missing" }),
|
|
] });
|
|
});
|
|
|
|
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");
|
|
writeFileSync(validUsers, registryYaml("user"), { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(validUsers, 0o600);
|
|
const validReport = await createAuthDiagnoser({
|
|
authMode: "local", authStateRoot: validRoot,
|
|
sessionRootValidator: validateAuthSessionRoot,
|
|
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,
|
|
sessionRootValidator: validateAuthSessionRoot,
|
|
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,
|
|
sessionRootValidator: validateAuthSessionRoot,
|
|
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" }] },
|
|
}).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",
|
|
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 () => [] },
|
|
}).inspect({ live: true });
|
|
|
|
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) => {
|
|
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.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" }],
|
|
["an unsafe foreign issuer", 200, {
|
|
issuer: "http://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"],
|
|
subject_types_supported: ["public"],
|
|
id_token_signing_alg_values_supported: ["RS256"],
|
|
}],
|
|
["an unsafe authorization endpoint", 200, {
|
|
issuer: "https://different-issuer.example.test",
|
|
authorization_endpoint: "http://127.0.0.1/authorize",
|
|
token_endpoint: "https://issuer.example.test/token",
|
|
jwks_uri: "https://issuer.example.test/jwks",
|
|
response_types_supported: ["code"],
|
|
subject_types_supported: ["public"],
|
|
id_token_signing_alg_values_supported: ["RS256"],
|
|
}],
|
|
["an unsafe token endpoint", 200, {
|
|
issuer: "https://different-issuer.example.test",
|
|
authorization_endpoint: "https://issuer.example.test/authorize",
|
|
token_endpoint: "https://operator:secret@issuer.example.test/token",
|
|
jwks_uri: "https://issuer.example.test/jwks",
|
|
response_types_supported: ["code"],
|
|
subject_types_supported: ["public"],
|
|
id_token_signing_alg_values_supported: ["RS256"],
|
|
}],
|
|
["an unsafe JWKS endpoint", 200, {
|
|
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#fragment",
|
|
response_types_supported: ["code"],
|
|
subject_types_supported: ["public"],
|
|
id_token_signing_alg_values_supported: ["RS256"],
|
|
}],
|
|
])("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({
|
|
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);
|
|
});
|
|
|
|
test.skipIf(process.platform === "win32")("uses the runtime validator for canonical, private session roots", async () => {
|
|
const dependencies = (authStateRoot: string) => ({
|
|
authMode: "none" as const,
|
|
authStateRoot,
|
|
sessionRootValidator: validateAuthSessionRoot,
|
|
});
|
|
const valid = privateRoot();
|
|
await expect(createAuthDiagnoser(dependencies(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 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)}`;
|
|
|
|
const missingReport = await createAuthDiagnoser(dependencies(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(dependencies(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(dependencies(valid)).inspect({ live: false }))
|
|
.resolves.toMatchObject({ ready: false, checks: [expect.objectContaining({ code: "auth_session_store_invalid" })] });
|
|
});
|
|
|
|
test.skipIf(process.platform === "win32")("diagnoses unsafe existing session-store children without creating missing children", async () => {
|
|
const root = privateRoot();
|
|
const outside = privateRoot();
|
|
symlinkSync(outside, join(root, "sessions"));
|
|
|
|
const linked = await createAuthDiagnoser({
|
|
authMode: "none", authStateRoot: root, sessionRootValidator: validateAuthSessionRoot,
|
|
}).inspect({ live: false });
|
|
expect(linked).toMatchObject({
|
|
ready: false,
|
|
checks: [expect.objectContaining({ code: "auth_session_store_invalid" })],
|
|
});
|
|
expect(existsSync(join(root, "oidc"))).toBe(false);
|
|
expect(JSON.stringify(linked)).not.toContain(root);
|
|
|
|
rmSync(join(root, "sessions"));
|
|
mkdirSync(join(root, "sessions"), { mode: 0o700 });
|
|
chmodSync(join(root, "sessions"), 0o700);
|
|
mkdirSync(join(root, "oidc"), { mode: 0o700 });
|
|
chmodSync(join(root, "oidc"), 0o750);
|
|
const nonPrivate = await createAuthDiagnoser({
|
|
authMode: "none", authStateRoot: root, sessionRootValidator: validateAuthSessionRoot,
|
|
}).inspect({ live: false });
|
|
expect(nonPrivate).toMatchObject({
|
|
ready: false,
|
|
checks: [expect.objectContaining({ code: "auth_session_store_invalid" })],
|
|
});
|
|
});
|
|
|
|
test.skipIf(process.platform === "win32")("routes production POSIX static validation through the native auth-storage bridge", async () => {
|
|
const validateRoot = vi.fn(async () => undefined);
|
|
const report = await createAuthDiagnoser({
|
|
authMode: "none",
|
|
authStateRoot: "/var/lib/thothii/auth",
|
|
posixStorageBridge: { validateRoot },
|
|
}).inspect({ live: false });
|
|
|
|
expect(report).toMatchObject({ ready: true });
|
|
expect(validateRoot).toHaveBeenCalledOnce();
|
|
expect(validateRoot).toHaveBeenCalledWith("/var/lib/thothii/auth");
|
|
});
|
|
|
|
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`); });
|
|
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);
|
|
});
|