fix(auth): make diagnostics match runtime safety
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
@@ -7,7 +7,8 @@ import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
test("configured OIDC advertises login but fails closed without its runtime client secret", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "thothii-app-oidc-mode-"));
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-app-oidc-mode-"));
|
||||
chmodSync(directory, 0o700);
|
||||
const file = join(directory, "auth.yaml");
|
||||
writeFileSync(file, stringify({
|
||||
version: 1, mode: "oidc", publicUrl: "https://thothii.example.org",
|
||||
@@ -17,7 +18,8 @@ test("configured OIDC advertises login but fails closed without its runtime clie
|
||||
},
|
||||
groupCatalog: { driver: "authentik", baseUrl: "https://authentik.example.org", apiTokenRef: "THT_AUTHENTIK_API_TOKEN" },
|
||||
authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } },
|
||||
}), "utf8");
|
||||
}), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
try {
|
||||
const app = buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: join(directory, "auth-state") }));
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
chmodSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
import {
|
||||
createAuthenticationConfigProvider,
|
||||
@@ -33,10 +43,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
function writeFixture(value: unknown): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "thothii-auth-config-"));
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-"));
|
||||
chmodSync(directory, 0o700);
|
||||
directories.push(directory);
|
||||
const file = join(directory, "auth.yaml");
|
||||
writeFileSync(file, stringify(value), "utf8");
|
||||
writeFileSync(file, stringify(value), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -214,7 +226,8 @@ test("provider reloads after an atomic configuration replacement", () => {
|
||||
const provider = createAuthenticationConfigProvider(file);
|
||||
const original = provider.current();
|
||||
const replacement = `${file}.replacement`;
|
||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), "utf8");
|
||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(replacement, 0o600);
|
||||
renameSync(replacement, file);
|
||||
|
||||
const reloaded = provider.current();
|
||||
@@ -225,13 +238,91 @@ test("provider reloads after an atomic configuration replacement", () => {
|
||||
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");
|
||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(replacement, 0o600);
|
||||
const provider = createAuthenticationConfigProvider(file);
|
||||
readHook.callback = () => renameSync(replacement, file);
|
||||
|
||||
expect(provider.current().value.publicUrl).toBe("http://127.0.0.1:9999");
|
||||
});
|
||||
|
||||
test.each(["symlink", "hard link", "mode wider than 0600", "non-private parent"])(
|
||||
"rejects auth.yaml with unsafe %s storage",
|
||||
(kind) => {
|
||||
const file = writeFixture(localConfig());
|
||||
if (kind === "symlink") {
|
||||
const target = `${file}.target`;
|
||||
renameSync(file, target);
|
||||
symlinkSync(target, file);
|
||||
} else if (kind === "hard link") linkSync(file, `${file}.link`);
|
||||
else if (kind === "mode wider than 0600") chmodSync(file, 0o640);
|
||||
else chmodSync(dirname(file), 0o750);
|
||||
|
||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects auth.yaml beneath a symlinked parent without exposing its path", () => {
|
||||
const outer = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-parent-"));
|
||||
chmodSync(outer, 0o700);
|
||||
directories.push(outer);
|
||||
const realDirectory = join(outer, "real-auth");
|
||||
const linkedDirectory = join(outer, "linked-auth");
|
||||
mkdirSync(realDirectory, { mode: 0o700 });
|
||||
chmodSync(realDirectory, 0o700);
|
||||
const realFile = join(realDirectory, "auth.yaml");
|
||||
writeFileSync(realFile, stringify(localConfig()), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(realFile, 0o600);
|
||||
symlinkSync(realDirectory, linkedDirectory);
|
||||
const unsafePath = join(linkedDirectory, "auth.yaml");
|
||||
|
||||
try {
|
||||
loadAuthenticationConfig(unsafePath);
|
||||
throw new Error("unsafe auth configuration unexpectedly loaded");
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toBe("authentication configuration is invalid");
|
||||
expect(String(error)).not.toContain(unsafePath);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects auth.yaml when its owner is not the runtime owner", () => {
|
||||
const geteuid = process.geteuid;
|
||||
if (!geteuid) return;
|
||||
const owner = geteuid();
|
||||
const file = writeFixture(localConfig());
|
||||
const spy = vi.spyOn(process, "geteuid").mockReturnValue(owner + 1);
|
||||
try {
|
||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider redacts an absent canonical path", () => {
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-absent-"));
|
||||
chmodSync(directory, 0o700);
|
||||
directories.push(directory);
|
||||
const missing = join(directory, "private-path-UNIQUE-4K6.yaml");
|
||||
|
||||
try {
|
||||
createAuthenticationConfigProvider(missing).current();
|
||||
throw new Error("missing authentication configuration unexpectedly loaded");
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toBe("authentication configuration is invalid");
|
||||
expect(String(error)).not.toContain(missing);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a path replacement during the bounded auth.yaml read", () => {
|
||||
const file = writeFixture(localConfig());
|
||||
const replacement = `${file}.replacement`;
|
||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(replacement, 0o600);
|
||||
readHook.callback = () => renameSync(replacement, file);
|
||||
|
||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||
});
|
||||
|
||||
test("rejects input larger than one MiB", () => {
|
||||
const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`);
|
||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { chmodSync, 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 { OidcJwksUnavailableError } from "../src/auth/oidc-client.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 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 {
|
||||
@@ -21,40 +41,64 @@ function oidcConfig(): LoadedAuthConfig {
|
||||
};
|
||||
}
|
||||
|
||||
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 groupCatalog = { verifyConfiguredGroups: vi.fn(async (names: readonly string[]) => {
|
||||
expect(names).toEqual(["TOT Admin", "TOT Users"]);
|
||||
return [];
|
||||
}) };
|
||||
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(groupCatalog.verifyConfiguredGroups).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(),
|
||||
authentication: { current: () => ({
|
||||
revision: "b".repeat(64), sourcePath: "/safe/auth.yaml",
|
||||
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" },
|
||||
},
|
||||
}) },
|
||||
hasEnabledLocalAdmin: async () => false,
|
||||
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: [
|
||||
@@ -65,9 +109,53 @@ test("requires both fixed OIDC and Authentik secrets only in OIDC mode", async (
|
||||
] });
|
||||
});
|
||||
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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" }] },
|
||||
@@ -80,6 +168,7 @@ test("maps OIDC and group catalog failures to only the closed diagnostics code u
|
||||
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 () => [] },
|
||||
@@ -88,6 +177,40 @@ test("reports a JWKS validation failure through its closed diagnostic code", asy
|
||||
expect(report.checks).toEqual([expect.objectContaining({ code: "oidc_jwks_unreachable" })]);
|
||||
});
|
||||
|
||||
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("redacts exceptional configuration, registry, protocol, and catalog errors", async () => {
|
||||
const detail = sentinels.join(" ");
|
||||
const report = await createAuthDiagnoser({
|
||||
@@ -100,3 +223,41 @@ test("redacts exceptional configuration, registry, protocol, and catalog errors"
|
||||
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 valid = privateRoot();
|
||||
await expect(createAuthDiagnoser({ authMode: "none", authStateRoot: 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 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 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);
|
||||
}
|
||||
|
||||
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("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);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const fsHooks = vi.hoisted(() => ({
|
||||
@@ -66,6 +66,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
import {
|
||||
createFileAuthSessionStore,
|
||||
deriveCsrfToken,
|
||||
validateAuthSessionRoot,
|
||||
type AuthSessionStore,
|
||||
type FileAuthSessionStoreOptions,
|
||||
type SessionCreateInput,
|
||||
@@ -241,6 +242,41 @@ async function isolatedOidcCreator(storageRoot: string, attempts: number): Promi
|
||||
}
|
||||
|
||||
describe("file-backed auth session store", () => {
|
||||
test.skipIf(process.platform === "win32")("exports its side-effect-free canonical session-root validator", () => {
|
||||
const valid = root();
|
||||
expect(() => validateAuthSessionRoot(valid)).not.toThrow();
|
||||
|
||||
const outer = root();
|
||||
const realRoot = join(outer, "real-auth");
|
||||
mkdirSync(realRoot, { mode: 0o700 });
|
||||
chmodSync(realRoot, 0o700);
|
||||
const linkedRoot = join(outer, "linked-auth");
|
||||
symlinkSync(realRoot, linkedRoot);
|
||||
const traversal = `${valid}/../${basename(valid)}`;
|
||||
const absent = join(outer, "absent-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(unsafe)).toThrow("auth_session_store_invalid");
|
||||
}
|
||||
|
||||
chmodSync(valid, 0o750);
|
||||
expect(() => validateAuthSessionRoot(valid)).toThrow("auth_session_store_invalid");
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("rejects a session root owned by another identity", () => {
|
||||
const storageRoot = root();
|
||||
fsHooks.transformLstat = (observed, info) => {
|
||||
if (observed !== storageRoot) return info;
|
||||
const foreign = Object.create(info) as import("node:fs").Stats;
|
||||
Object.defineProperty(foreign, "uid", { value: info.uid + 1 });
|
||||
return foreign;
|
||||
};
|
||||
|
||||
expect(() => validateAuthSessionRoot(storageRoot)).toThrow("auth_session_store_invalid");
|
||||
});
|
||||
|
||||
test("fails closed and revokes a session when constructed without validity dependencies", async () => {
|
||||
const storageRoot = root();
|
||||
const store = createFileAuthSessionStore(storageRoot);
|
||||
|
||||
@@ -84,6 +84,47 @@ test("refuses declared and streamed group catalog bodies larger than one MiB", a
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
["malformed", "not-a-number"],
|
||||
["oversized", String(1024 * 1024 + 1)],
|
||||
])("cancels a %s declared-size body without waiting for hanging cancellation", async (_caseName, contentLength) => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream({
|
||||
pull() { /* early declared-size rejection must not read */ },
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
return new Promise<void>(() => { /* cancellation remains advisory */ });
|
||||
},
|
||||
});
|
||||
const completion = catalog(vi.fn<typeof globalThis.fetch>(async () => new Response(body, {
|
||||
headers: { "content-length": contentLength },
|
||||
}))).verifyConfiguredGroups(["TOT Users"], new AbortController().signal);
|
||||
|
||||
await expect(Promise.race([
|
||||
completion,
|
||||
new Promise((resolve) => setTimeout(() => resolve("timed-out"), 100)),
|
||||
])).resolves.toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]);
|
||||
expect(cancelled).toBe(true);
|
||||
expect(body.locked).toBe(false);
|
||||
});
|
||||
|
||||
test("contains a rejected declared-size cancellation without an unhandled rejection", async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream({
|
||||
pull() { /* early declared-size rejection must not read */ },
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
return Promise.reject(new Error("cancellation-detail-must-stay-contained"));
|
||||
},
|
||||
});
|
||||
|
||||
await expect(catalog(vi.fn<typeof globalThis.fetch>(async () => new Response(body, {
|
||||
headers: { "content-length": "invalid" },
|
||||
}))).verifyConfiguredGroups(["TOT Users"], new AbortController().signal))
|
||||
.resolves.toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
test("aborts a hanging request at five seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
function authFile(value: unknown): { directory: string; file: string } {
|
||||
const directory = mkdtempSync(join(tmpdir(), "thothii-app-auth-config-"));
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-app-auth-config-"));
|
||||
chmodSync(directory, 0o700);
|
||||
const file = join(directory, "auth.yaml");
|
||||
writeFileSync(file, stringify(value), "utf8");
|
||||
writeFileSync(file, stringify(value), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
return { directory, file };
|
||||
}
|
||||
|
||||
@@ -109,7 +111,7 @@ test("loadConfig makes an existing auth.yaml authoritative and rejects AUTH_MODE
|
||||
});
|
||||
|
||||
test("loadConfig rejects an auth config path that exists but is not a regular file", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "thothii-app-auth-config-directory-"));
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-app-auth-config-directory-"));
|
||||
try {
|
||||
expect(() => loadConfig({ THT_AUTH_CONFIG_FILE: directory }))
|
||||
.toThrow("authentication configuration is invalid");
|
||||
|
||||
@@ -125,6 +125,18 @@ describe("local user registry", () => {
|
||||
await expect(registry.verify(await registry.findByUsername("operator"), password)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("reports whether a structurally valid registry has an enabled administrator", async () => {
|
||||
const admin = createLocalUserRegistry(writeRegistry(registryYaml(userYaml())).path);
|
||||
const usersOnly = createLocalUserRegistry(writeRegistry(registryYaml(userYaml({ role: "user" }))).path);
|
||||
const disabledAdmin = createLocalUserRegistry(writeRegistry(registryYaml(userYaml({ enabled: false }))).path);
|
||||
|
||||
await expect(admin.hasEnabledAdmin()).resolves.toBe(true);
|
||||
await expect(usersOnly.hasEnabledAdmin()).resolves.toBe(false);
|
||||
await expect(disabledAdmin.hasEnabledAdmin()).resolves.toBe(false);
|
||||
await expectInvalid(usersOnly.findByUsername("admin"));
|
||||
await expectInvalid(disabledAdmin.findBySubject(adminId));
|
||||
});
|
||||
|
||||
test("rejects a valid registry under a non-private authentication directory", async () => {
|
||||
const fixture = writeRegistry(registryYaml(userYaml()));
|
||||
chmodSync(fixture.root, 0o750);
|
||||
@@ -151,7 +163,6 @@ describe("local user registry", () => {
|
||||
["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))],
|
||||
["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))],
|
||||
["unknown YAML fields", `${registryYaml(userYaml())}unexpected: true\n`],
|
||||
["no enabled admin", registryYaml(userYaml({ role: "user" }))],
|
||||
["duplicate roles", registryYaml(userYaml().replace(" - admin", " - admin\n - admin"))],
|
||||
["invalid password hash", registryYaml(userYaml().replace(passwordHash, "not-a-password-hash"))],
|
||||
["control character in display name", registryYaml(userYaml().replace("displayName: Admin", 'displayName: "Admin\\tUser"'))],
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { createSign, generateKeyPairSync } from "node:crypto";
|
||||
import { expect, test } from "vitest";
|
||||
import { createOidcProtocol, OidcJwksUnavailableError, OidcProtocolError, OidcProviderUnavailableError } from "../src/auth/oidc-client.js";
|
||||
import {
|
||||
createOidcProtocol,
|
||||
OidcIssuerMismatchError,
|
||||
OidcJwksUnavailableError,
|
||||
OidcProtocolError,
|
||||
OidcProviderUnavailableError,
|
||||
} from "../src/auth/oidc-client.js";
|
||||
|
||||
const issuer = "https://issuer.example.test";
|
||||
const clientId = "thothii";
|
||||
@@ -284,8 +290,14 @@ test("rejects non-HTTPS issuer configuration and a discovery issuer mismatch", a
|
||||
issuer: "http://issuer.example.test", clientId, clientSecret: "secret", callbackUrl,
|
||||
scopes: ["openid"], groupsClaim: "groups",
|
||||
})).toThrow(OidcProtocolError);
|
||||
await expect(protocol({ discoveryIssuer: "https://other.example.test" }).authorizationUrl({ state, nonce, codeVerifier: verifier }))
|
||||
.rejects.toThrow(OidcProtocolError);
|
||||
try {
|
||||
await protocol({ discoveryIssuer: "https://other.example.test" })
|
||||
.authorizationUrl({ state, nonce, codeVerifier: verifier });
|
||||
throw new Error("issuer mismatch unexpectedly accepted");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(OidcIssuerMismatchError);
|
||||
expect((error as Error).message).toBe("oidc_issuer_mismatch");
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
||||
Reference in New Issue
Block a user