fix(auth): fail closed configuration compatibility
This commit is contained in:
@@ -137,6 +137,9 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels });
|
const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels });
|
||||||
|
|
||||||
const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile);
|
const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile);
|
||||||
|
if (config.authMode === "local" || config.authMode === "oidc") {
|
||||||
|
throw new Error("configured authentication mode is not implemented");
|
||||||
|
}
|
||||||
const authenticate = authPreHandler(config.authMode);
|
const authenticate = authPreHandler(config.authMode);
|
||||||
app.addHook("preHandler", async (req, reply) => {
|
app.addHook("preHandler", async (req, reply) => {
|
||||||
// Process readiness is intentionally unauthenticated for local container/proxy probes.
|
// Process readiness is intentionally unauthenticated for local container/proxy probes.
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||||
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
|
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
|
||||||
import type { AuthMode } from "./types.js";
|
|
||||||
|
|
||||||
declare module "fastify" {
|
declare module "fastify" {
|
||||||
interface FastifyRequest { principal?: PrincipalContext }
|
interface FastifyRequest { principal?: PrincipalContext }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authPreHandler(mode: AuthMode) {
|
export function authPreHandler(mode: "none" | "mock" | "upstream") {
|
||||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
if (mode === "none") {
|
if (mode === "none") {
|
||||||
req.principal = localPrincipal();
|
req.principal = localPrincipal();
|
||||||
|
|||||||
+23
-10
@@ -59,7 +59,9 @@ const oidcSchema = z.strictObject({
|
|||||||
authorization: z.strictObject({ groupRoles: groupRolesSchema }),
|
authorization: z.strictObject({ groupRoles: groupRolesSchema }),
|
||||||
});
|
});
|
||||||
|
|
||||||
function readBoundedConfig(path: string): string {
|
interface FileIdentity { dev: number; ino: number; size: number; mtimeMs: number }
|
||||||
|
|
||||||
|
function readBoundedConfig(path: string): { source: string; identity: FileIdentity } {
|
||||||
let fd: number | undefined;
|
let fd: number | undefined;
|
||||||
try {
|
try {
|
||||||
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||||
@@ -68,7 +70,10 @@ function readBoundedConfig(path: string): string {
|
|||||||
const buffer = Buffer.allocUnsafe(MAX_AUTH_CONFIG_BYTES + 1);
|
const buffer = Buffer.allocUnsafe(MAX_AUTH_CONFIG_BYTES + 1);
|
||||||
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
|
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
|
||||||
if (bytesRead > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
if (bytesRead > MAX_AUTH_CONFIG_BYTES) throw invalid();
|
||||||
return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead));
|
return {
|
||||||
|
source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)),
|
||||||
|
identity: { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs },
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
throw invalid();
|
throw invalid();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -105,7 +110,7 @@ function canonicalize(value: unknown): unknown {
|
|||||||
if (Array.isArray(value)) return value.map(canonicalize);
|
if (Array.isArray(value)) return value.map(canonicalize);
|
||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
||||||
.map(([key, nested]) => [key, canonicalize(nested)]));
|
.map(([key, nested]) => [key, canonicalize(nested)]));
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
@@ -139,13 +144,16 @@ function parseAuthenticationConfig(source: string): AuthenticationConfig {
|
|||||||
} catch { throw invalid(); }
|
} catch { throw invalid(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAuthenticationConfig(path: string): LoadedAuthConfig {
|
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: FileIdentity } {
|
||||||
if (typeof path !== "string" || path.length === 0 || path.trim() !== path || path.includes("\0")) throw invalid();
|
if (typeof path !== "string" || path.length === 0 || path.trim() !== path || path.includes("\0")) throw invalid();
|
||||||
const value = parseAuthenticationConfig(readBoundedConfig(path));
|
const read = readBoundedConfig(path);
|
||||||
return { value, revision: canonicalRevision(value), sourcePath: path };
|
const value = parseAuthenticationConfig(read.source);
|
||||||
|
return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileIdentity { dev: number; ino: number; size: number; mtimeMs: number }
|
export function loadAuthenticationConfig(path: string): LoadedAuthConfig {
|
||||||
|
return loadAuthenticationConfigWithIdentity(path).loaded;
|
||||||
|
}
|
||||||
|
|
||||||
function fileIdentity(path: string): FileIdentity {
|
function fileIdentity(path: string): FileIdentity {
|
||||||
try {
|
try {
|
||||||
@@ -164,9 +172,14 @@ export function createAuthenticationConfigProvider(path: string): Authentication
|
|||||||
return { current(): LoadedAuthConfig {
|
return { current(): LoadedAuthConfig {
|
||||||
const before = fileIdentity(path);
|
const before = fileIdentity(path);
|
||||||
if (cached && sameIdentity(cached.identity, before)) return cached.loaded;
|
if (cached && sameIdentity(cached.identity, before)) return cached.loaded;
|
||||||
const loaded = loadAuthenticationConfig(path);
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
cached = { identity: fileIdentity(path), loaded };
|
const { loaded, identity } = loadAuthenticationConfigWithIdentity(path);
|
||||||
return loaded;
|
if (sameIdentity(identity, fileIdentity(path))) {
|
||||||
|
cached = { identity, loaded };
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw invalid();
|
||||||
} };
|
} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,11 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
|||||||
if (!(["none", "mock", "upstream"] as const).includes(requestedMode as "none" | "mock" | "upstream")) {
|
if (!(["none", "mock", "upstream"] as const).includes(requestedMode as "none" | "mock" | "upstream")) {
|
||||||
throw new Error(`unsupported AUTH_MODE=${requestedMode}; use none, mock, or upstream`);
|
throw new Error(`unsupported AUTH_MODE=${requestedMode}; use none, mock, or upstream`);
|
||||||
}
|
}
|
||||||
|
const nodeEnvironment = env.NODE_ENV ?? process.env.NODE_ENV;
|
||||||
|
if ((requestedMode === "none" || requestedMode === "mock")
|
||||||
|
&& nodeEnvironment !== "development" && nodeEnvironment !== "test") {
|
||||||
|
throw new Error("production requires auth.yaml or AUTH_MODE=upstream");
|
||||||
|
}
|
||||||
authMode = requestedMode as "none" | "mock" | "upstream";
|
authMode = requestedMode as "none" | "mock" | "upstream";
|
||||||
}
|
}
|
||||||
const publicExposure = env.THOTH_PUBLIC_EXPOSURE === "true";
|
const publicExposure = env.THOTH_PUBLIC_EXPOSURE === "true";
|
||||||
|
|||||||
@@ -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 { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
@@ -9,6 +10,22 @@ import {
|
|||||||
rolesToPermissions,
|
rolesToPermissions,
|
||||||
} from "../src/auth/config.js";
|
} 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[] = [];
|
const directories: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -157,6 +174,22 @@ test("canonical group map order produces one stable revision", () => {
|
|||||||
expect(loadAuthenticationConfig(first).revision).toBe(loadAuthenticationConfig(reordered).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", () => {
|
test("provider reloads after an atomic configuration replacement", () => {
|
||||||
const file = writeFixture(localConfig());
|
const file = writeFixture(localConfig());
|
||||||
const provider = createAuthenticationConfigProvider(file);
|
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");
|
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", () => {
|
test("rejects input larger than one MiB", () => {
|
||||||
const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`);
|
const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`);
|
||||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||||
|
|||||||
@@ -71,6 +71,23 @@ test("loadConfig keeps local development defaults", () => {
|
|||||||
expect(loadConfig({}).dataRoot).toBeUndefined();
|
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", () => {
|
test("loadConfig makes an existing auth.yaml authoritative and rejects AUTH_MODE split-brain", () => {
|
||||||
const { directory, file } = authFile(oidcAuthConfig());
|
const { directory, file } = authFile(oidcAuthConfig());
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user