fix(auth): fail closed configuration compatibility
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
test("configured OIDC fails app startup until an OIDC handler is installed", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "thothii-app-oidc-mode-"));
|
||||
const file = join(directory, "auth.yaml");
|
||||
writeFileSync(file, stringify({
|
||||
version: 1, mode: "oidc", publicUrl: "https://thothii.example.org",
|
||||
oidc: {
|
||||
issuer: "https://authentik.example.org/application/o/thothii/", clientId: "thothii",
|
||||
clientSecretRef: "THT_OIDC_CLIENT_SECRET", scopes: ["openid"], groupsClaim: "groups",
|
||||
},
|
||||
groupCatalog: { driver: "authentik", baseUrl: "https://authentik.example.org", apiTokenRef: "THT_AUTHENTIK_API_TOKEN" },
|
||||
authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } },
|
||||
}), "utf8");
|
||||
try {
|
||||
expect(() => buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file })))
|
||||
.toThrow("configured authentication mode is not implemented");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
@@ -9,6 +10,22 @@ import {
|
||||
rolesToPermissions,
|
||||
} from "../src/auth/config.js";
|
||||
|
||||
const readHook = vi.hoisted(() => ({ callback: undefined as undefined | (() => void) }));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
readSync: (...args: any[]) => {
|
||||
const result = (actual.readSync as any)(...args);
|
||||
const callback = readHook.callback;
|
||||
readHook.callback = undefined;
|
||||
callback?.();
|
||||
return result;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
@@ -157,6 +174,22 @@ test("canonical group map order produces one stable revision", () => {
|
||||
expect(loadAuthenticationConfig(first).revision).toBe(loadAuthenticationConfig(reordered).revision);
|
||||
});
|
||||
|
||||
test("canonical revisions use code-unit ordering for non-ASCII group names", () => {
|
||||
const loaded = loadAuthenticationConfig(writeFixture(oidcConfig({
|
||||
authorization: { groupRoles: { "Ångström users": ["user"], "Zebra admins": ["admin"] } },
|
||||
})));
|
||||
const canonicalize = (value: unknown): unknown => Array.isArray(value)
|
||||
? value.map(canonicalize)
|
||||
: value && typeof value === "object"
|
||||
? Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
||||
.map(([key, nested]) => [key, canonicalize(nested)]))
|
||||
: value;
|
||||
const expected = createHash("sha256").update(JSON.stringify(canonicalize(loaded.value))).digest("hex");
|
||||
|
||||
expect(loaded.revision).toBe(expected);
|
||||
});
|
||||
|
||||
test("provider reloads after an atomic configuration replacement", () => {
|
||||
const file = writeFixture(localConfig());
|
||||
const provider = createAuthenticationConfigProvider(file);
|
||||
@@ -170,6 +203,16 @@ test("provider reloads after an atomic configuration replacement", () => {
|
||||
expect(reloaded.value.publicUrl).toBe("http://127.0.0.1:9999");
|
||||
});
|
||||
|
||||
test("provider retries when replacement occurs between its read and cache identity check", () => {
|
||||
const file = writeFixture(localConfig());
|
||||
const replacement = `${file}.replacement`;
|
||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), "utf8");
|
||||
const provider = createAuthenticationConfigProvider(file);
|
||||
readHook.callback = () => renameSync(replacement, file);
|
||||
|
||||
expect(provider.current().value.publicUrl).toBe("http://127.0.0.1:9999");
|
||||
});
|
||||
|
||||
test("rejects input larger than one MiB", () => {
|
||||
const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`);
|
||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||
|
||||
@@ -71,6 +71,23 @@ test("loadConfig keeps local development defaults", () => {
|
||||
expect(loadConfig({}).dataRoot).toBeUndefined();
|
||||
});
|
||||
|
||||
test("loadConfig allows none and mock only outside production when auth.yaml is absent", () => {
|
||||
const originalNodeEnvironment = process.env.NODE_ENV;
|
||||
delete process.env.NODE_ENV;
|
||||
try {
|
||||
expect(() => loadConfig({ AUTH_MODE: "none" })).toThrow("production requires auth.yaml or AUTH_MODE=upstream");
|
||||
} finally {
|
||||
if (originalNodeEnvironment === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = originalNodeEnvironment;
|
||||
}
|
||||
expect(loadConfig({ NODE_ENV: "test", AUTH_MODE: "mock" }).authMode).toBe("mock");
|
||||
expect(loadConfig({ NODE_ENV: "development", AUTH_MODE: "none" }).authMode).toBe("none");
|
||||
expect(loadConfig({ NODE_ENV: "production", AUTH_MODE: "upstream" }).authMode).toBe("upstream");
|
||||
expect(() => loadConfig({ NODE_ENV: "production" })).toThrow("production requires auth.yaml or AUTH_MODE=upstream");
|
||||
expect(() => loadConfig({ NODE_ENV: "production", AUTH_MODE: "mock" }))
|
||||
.toThrow("production requires auth.yaml or AUTH_MODE=upstream");
|
||||
});
|
||||
|
||||
test("loadConfig makes an existing auth.yaml authoritative and rejects AUTH_MODE split-brain", () => {
|
||||
const { directory, file } = authFile(oidcAuthConfig());
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user