fix(auth): make diagnostics bounded and portable
This commit is contained in:
@@ -167,6 +167,18 @@ test("rejects unknown roles and requires exactly one admin group", () => {
|
||||
})))).toThrow("authentication configuration is invalid");
|
||||
});
|
||||
|
||||
test("caps configured group mappings at the OIDC direct-groups bound", () => {
|
||||
const mappings = Object.fromEntries(Array.from({ length: 128 }, (_unused, index) => [
|
||||
`Mapped Group ${String(index).padStart(3, "0")}`,
|
||||
index === 0 ? ["admin"] : ["user"],
|
||||
]));
|
||||
expect(loadAuthenticationConfig(writeFixture(oidcConfig({ authorization: { groupRoles: mappings } }))).value.mode)
|
||||
.toBe("oidc");
|
||||
mappings["Mapped Group overflow"] = ["user"];
|
||||
expect(() => loadAuthenticationConfig(writeFixture(oidcConfig({ authorization: { groupRoles: mappings } }))))
|
||||
.toThrow("authentication configuration is invalid");
|
||||
});
|
||||
|
||||
test("roles collapse duplicates and admin contains all administrative permissions", () => {
|
||||
expect(rolesToPermissions(["admin", "admin", "user"])).toEqual([
|
||||
"session.use",
|
||||
@@ -235,6 +247,29 @@ test("provider reloads after an atomic configuration replacement", () => {
|
||||
expect(reloaded.value.publicUrl).toBe("http://127.0.0.1:9999");
|
||||
});
|
||||
|
||||
test("loads and reloads Windows auth.yaml through the production storage bridge boundary", () => {
|
||||
const originalPlatform = process.platform;
|
||||
const windowsPath = "C:\\ProgramData\\ThothII\\auth\\auth.yaml";
|
||||
let source = stringify(localConfig());
|
||||
const readAuthConfig = vi.fn(() => Buffer.from(source));
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
|
||||
try {
|
||||
const options = { windowsStorageBridge: { readAuthConfig } as never };
|
||||
expect(loadAuthenticationConfig(windowsPath, options).value.publicUrl).toBe("http://127.0.0.1:8080");
|
||||
const provider = createAuthenticationConfigProvider(windowsPath, options);
|
||||
const original = provider.current();
|
||||
source = stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" }));
|
||||
const reloaded = provider.current();
|
||||
|
||||
expect(reloaded.value.publicUrl).toBe("http://127.0.0.1:9999");
|
||||
expect(reloaded.revision).not.toBe(original.revision);
|
||||
expect(readAuthConfig).toHaveBeenCalledTimes(3);
|
||||
expect(readAuthConfig).toHaveBeenCalledWith(windowsPath);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
}
|
||||
});
|
||||
|
||||
test("provider retries when replacement occurs between its read and cache identity check", () => {
|
||||
const file = writeFixture(localConfig());
|
||||
const replacement = `${file}.replacement`;
|
||||
|
||||
@@ -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`); });
|
||||
|
||||
@@ -254,17 +254,69 @@ describe("file-backed auth session store", () => {
|
||||
symlinkSync(realRoot, linkedRoot);
|
||||
const traversal = `${valid}/../${basename(valid)}`;
|
||||
const absent = join(outer, "absent-auth");
|
||||
const absentNested = join(outer, "absent-parent", "auth");
|
||||
const fileParent = join(outer, "not-a-directory");
|
||||
writeFileSync(fileParent, "blocked", { mode: 0o600 });
|
||||
|
||||
for (const unsafe of [traversal, linkedRoot, absent, join(fileParent, "auth")]) {
|
||||
expect(() => validateAuthSessionRoot(absent)).not.toThrow();
|
||||
expect(existsSync(absent)).toBe(false);
|
||||
for (const unsafe of [traversal, linkedRoot, absentNested, join(fileParent, "auth")]) {
|
||||
expect(() => validateAuthSessionRoot(unsafe)).toThrow("auth_session_store_invalid");
|
||||
}
|
||||
expect(existsSync(join(outer, "absent-parent"))).toBe(false);
|
||||
|
||||
chmodSync(valid, 0o750);
|
||||
expect(() => validateAuthSessionRoot(valid)).toThrow("auth_session_store_invalid");
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("never follows a symlinked ancestor while creating a missing session root", async () => {
|
||||
const outer = root();
|
||||
const outside = root();
|
||||
const linkedParent = join(outer, "linked-parent");
|
||||
symlinkSync(outside, linkedParent);
|
||||
const storageRoot = join(linkedParent, "auth");
|
||||
|
||||
await expectStoreInvalid(create(validStore(storageRoot)));
|
||||
expect(existsSync(join(outside, "auth"))).toBe(false);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("rejects an uncreatable missing root without side effects in static and runtime paths", async () => {
|
||||
const outer = root();
|
||||
const lockedParent = join(outer, "locked-parent");
|
||||
mkdirSync(lockedParent, { mode: 0o700 });
|
||||
chmodSync(lockedParent, 0o500);
|
||||
const storageRoot = join(lockedParent, "auth");
|
||||
try {
|
||||
expect(() => validateAuthSessionRoot(storageRoot)).toThrow("auth_session_store_invalid");
|
||||
await expectStoreInvalid(create(validStore(storageRoot)));
|
||||
expect(existsSync(storageRoot)).toBe(false);
|
||||
} finally {
|
||||
chmodSync(lockedParent, 0o700);
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("detects an ancestor replacement before creating any session directory", async () => {
|
||||
const outer = root();
|
||||
const outside = root();
|
||||
const parent = join(outer, "parent");
|
||||
const movedParent = join(outer, "parent-original");
|
||||
mkdirSync(parent, { mode: 0o700 });
|
||||
chmodSync(parent, 0o700);
|
||||
let replaced = false;
|
||||
fsHooks.afterLstat = (observed) => {
|
||||
if (observed !== parent) return false;
|
||||
renameSync(parent, movedParent);
|
||||
symlinkSync(outside, parent);
|
||||
replaced = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
await expectStoreInvalid(create(validStore(join(parent, "auth"))));
|
||||
expect(replaced).toBe(true);
|
||||
expect(existsSync(join(outside, "auth"))).toBe(false);
|
||||
expect(existsSync(join(movedParent, "auth"))).toBe(false);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("rejects a session root owned by another identity", () => {
|
||||
const storageRoot = root();
|
||||
fsHooks.transformLstat = (observed, info) => {
|
||||
|
||||
@@ -43,6 +43,15 @@ test.each([
|
||||
expect(result).toEqual([expect.objectContaining({ level: "error", code, field: "TOT Users" })]);
|
||||
});
|
||||
|
||||
test("rejects more than the requested two bounded group results", async () => {
|
||||
const response = groups("TOT Users", "Unrelated One", "Unrelated Two");
|
||||
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" })]);
|
||||
expect(JSON.stringify(result)).not.toContain("Unrelated");
|
||||
});
|
||||
|
||||
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))
|
||||
@@ -51,6 +60,34 @@ test("treats a pagination continuation as an ambiguous configured group", async
|
||||
expect(result).toEqual([expect.objectContaining({ code: "oidc_mapped_group_ambiguous", field: "TOT Users" })]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["missing next", { pagination: {}, results: [{ name: "TOT Users" }] }],
|
||||
["numeric next", { pagination: { next: 2 }, results: [{ name: "TOT Users" }] }],
|
||||
["boolean next", { pagination: { next: false }, results: [{ name: "TOT Users" }] }],
|
||||
["malformed continuation", { pagination: { next: "not a URL" }, results: [{ name: "TOT Users" }] }],
|
||||
])("rejects a group response with %s as unreachable", async (_label, body) => {
|
||||
const result = await catalog(vi.fn<typeof globalThis.fetch>(async () => Response.json(body)))
|
||||
.verifyConfiguredGroups(["TOT Users"], new AbortController().signal);
|
||||
|
||||
expect(result).toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]);
|
||||
expect(result).not.toContainEqual(expect.objectContaining({ code: "oidc_mapped_group_ambiguous" }));
|
||||
});
|
||||
|
||||
test("enforces the exact Authentik token boundary before making a request", async () => {
|
||||
const fetch = vi.fn<typeof globalThis.fetch>(async () => groups("TOT Users"));
|
||||
const accepted = createAuthentikGroupCatalog({
|
||||
baseUrl: "https://authentik.example.test", apiToken: "a".repeat(16 * 1024), fetch,
|
||||
});
|
||||
const rejected = createAuthentikGroupCatalog({
|
||||
baseUrl: "https://authentik.example.test", apiToken: "a".repeat(16 * 1024 + 1), fetch,
|
||||
});
|
||||
|
||||
await expect(accepted.verifyConfiguredGroups(["TOT Users"], new AbortController().signal)).resolves.toEqual([]);
|
||||
await expect(rejected.verifyConfiguredGroups(["TOT Users"], new AbortController().signal))
|
||||
.resolves.toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]);
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
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"],
|
||||
|
||||
@@ -300,6 +300,17 @@ test("rejects non-HTTPS issuer configuration and a discovery issuer mismatch", a
|
||||
}
|
||||
});
|
||||
|
||||
test("enforces the exact shared OIDC client-secret boundary", () => {
|
||||
expect(() => createOidcProtocol({
|
||||
issuer, clientId, clientSecret: "s".repeat(4096), callbackUrl,
|
||||
scopes: ["openid"], groupsClaim: "groups",
|
||||
})).not.toThrow();
|
||||
expect(() => createOidcProtocol({
|
||||
issuer, clientId, clientSecret: "s".repeat(4097), callbackUrl,
|
||||
scopes: ["openid"], groupsClaim: "groups",
|
||||
})).toThrow(OidcProtocolError);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["authorization", { authorization_endpoint: "http://127.0.0.1/authorize" }],
|
||||
["token", { token_endpoint: "http://127.0.0.1/token" }],
|
||||
|
||||
@@ -30,6 +30,17 @@ test("accepts the fixed OIDC and Authentik secret references", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
test.each([
|
||||
["THT_OIDC_CLIENT_SECRET", 4096],
|
||||
["THT_AUTHENTIK_API_TOKEN", 16 * 1024],
|
||||
])("enforces the shared exact value boundary for %s", (name, maximum) => {
|
||||
expect(loadSecretBundle(bundle(`${name}=${"x".repeat(maximum)}\n`)).get(name)).toHaveLength(maximum);
|
||||
expect(() => loadSecretBundle(bundle(`${name}=${"x".repeat(maximum + 1)}\n`)))
|
||||
.toThrow("secret bundle is unavailable");
|
||||
expect(() => loadSecretBundle(bundle(`${name}=invalid\u0000secret\n`)))
|
||||
.toThrow("secret bundle is unavailable");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["duplicate", "THT_MODEL_API_KEY=a\nTHT_MODEL_API_KEY=b\n"],
|
||||
["unknown", "UNKNOWN_KEY=x\n"],
|
||||
|
||||
@@ -144,6 +144,42 @@ describe("Windows auth-storage bridge", () => {
|
||||
expect(calls[0].timeoutMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("validates Windows roots and reads auth.yaml through the same bounded hidden bridge", async () => {
|
||||
const asyncCalls: Array<Record<string, unknown>> = [];
|
||||
const syncCalls: Array<{ args: readonly string[]; input: Buffer; timeoutMs: number; maximumOutputBytes: number }> = [];
|
||||
const config = Buffer.from("version: 1\nmode: local\n");
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\Program Files\\ThothII\\tht.exe",
|
||||
invoke: async ({ input }) => {
|
||||
asyncCalls.push(JSON.parse(input.toString("utf8")) as Record<string, unknown>);
|
||||
return { code: 0, stdout: Buffer.from('{"version":1,"ok":true,"validated":true}\n'), stderr: Buffer.alloc(0) };
|
||||
},
|
||||
invokeSync: (call: { args: readonly string[]; input: Buffer; timeoutMs: number; maximumOutputBytes: number }) => {
|
||||
syncCalls.push(call);
|
||||
return {
|
||||
code: 0,
|
||||
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, found: true, contentBase64: config.toString("base64") })}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
};
|
||||
},
|
||||
} as never) as unknown as {
|
||||
validateRoot(root: string): Promise<void>;
|
||||
readAuthConfig(path: string): Buffer;
|
||||
};
|
||||
|
||||
await expect(bridge.validateRoot(root)).resolves.toBeUndefined();
|
||||
expect(bridge.readAuthConfig(`${root}\\auth.yaml`)).toEqual(config);
|
||||
expect(asyncCalls).toEqual([{ version: 1, operation: "validate-root", root }]);
|
||||
expect(JSON.parse(syncCalls[0]!.input.toString("utf8"))).toEqual({
|
||||
version: 1, operation: "read-auth-config", root, filename: "auth.yaml",
|
||||
});
|
||||
expect(syncCalls[0]!.args).toEqual(["_auth-storage"]);
|
||||
expect(syncCalls[0]!.timeoutMs).toBe(5_000);
|
||||
expect(syncCalls[0]!.maximumOutputBytes).toBeGreaterThan(1024 * 1024);
|
||||
expect(JSON.stringify(syncCalls[0]!.args)).not.toContain(root);
|
||||
expect(JSON.stringify(syncCalls[0]!.args)).not.toContain(config.toString("utf8"));
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: "nonzero", result: { code: 1, stdout: Buffer.from('{"version":1,"ok":true}\n'), stderr: Buffer.from("secret") } },
|
||||
{ label: "malformed stdout", result: { code: 0, stdout: Buffer.from("not-json"), stderr: Buffer.alloc(0) } },
|
||||
|
||||
Reference in New Issue
Block a user