From 7cfbee36fa9e2b83b27d26b9d0d85458fee32fd3 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 17 Aug 2026 11:23:52 +0200 Subject: [PATCH] fix(auth): make diagnostics match runtime safety --- backend/src/auth/authentik-group-catalog.ts | 20 +- backend/src/auth/config.ts | 162 +++++++++++++--- backend/src/auth/diagnostics.ts | 35 ++-- backend/src/auth/local-registry.ts | 18 +- backend/src/auth/oidc-client.ts | 39 +++- backend/src/auth/session-store.ts | 20 +- backend/test/app-auth-mode.test.ts | 8 +- backend/test/auth-config.test.ts | 103 +++++++++- backend/test/auth-diagnostics.test.ts | 193 +++++++++++++++++-- backend/test/auth-session-store.test.ts | 38 +++- backend/test/authentik-group-catalog.test.ts | 41 ++++ backend/test/config.test.ts | 10 +- backend/test/local-registry.test.ts | 13 +- backend/test/oidc-client.test.ts | 18 +- 14 files changed, 619 insertions(+), 99 deletions(-) diff --git a/backend/src/auth/authentik-group-catalog.ts b/backend/src/auth/authentik-group-catalog.ts index 6fab2294..8dd9bbf3 100644 --- a/backend/src/auth/authentik-group-catalog.ts +++ b/backend/src/auth/authentik-group-catalog.ts @@ -107,7 +107,10 @@ function validContentLength(response: Response): boolean { } async function readBounded(response: Response, signal: AbortSignal): Promise { - if (!validContentLength(response)) return undefined; + if (!validContentLength(response)) { + cancelResponse(response); + return undefined; + } const reader = response.body?.getReader(); if (!reader) return new Uint8Array(); const chunks: Uint8Array[] = []; @@ -144,12 +147,15 @@ function exactResult(name: string, parsed: unknown): GroupResult { || Array.isArray(record.pagination)) return "unreachable"; const next = (record.pagination as { next?: unknown }).next; if (next !== null && next !== undefined) return "ambiguous"; - if (record.results.length === 0) return "missing"; - if (record.results.length !== 1) return "ambiguous"; - const result = record.results[0]; - if (!result || typeof result !== "object" || Array.isArray(result) - || (result as { name?: unknown }).name !== name) return "missing"; - return "present"; + const resultNames: string[] = []; + for (const result of record.results) { + if (!result || typeof result !== "object" || Array.isArray(result) + || typeof (result as { name?: unknown }).name !== "string") return "unreachable"; + resultNames.push((result as { name: string }).name); + } + const exactMatches = resultNames.filter((candidate) => candidate === name).length; + if (exactMatches === 0) return "missing"; + return exactMatches === 1 ? "present" : "ambiguous"; } export function createAuthentikGroupCatalog(options: AuthentikGroupCatalogOptions): GroupCatalog { diff --git a/backend/src/auth/config.ts b/backend/src/auth/config.ts index 2b60cbd2..792470e5 100644 --- a/backend/src/auth/config.ts +++ b/backend/src/auth/config.ts @@ -1,5 +1,15 @@ import { createHash } from "node:crypto"; -import { closeSync, constants, fstatSync, openSync, readSync, statSync } from "node:fs"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync, +} from "node:fs"; +import type { Stats } from "node:fs"; +import { dirname, isAbsolute, normalize } from "node:path"; import { parseDocument } from "yaml"; import { z } from "zod"; import type { @@ -60,25 +70,126 @@ const oidcSchema = z.strictObject({ authorization: z.strictObject({ groupRoles: groupRolesSchema }), }); -interface FileIdentity { dev: number; ino: number; size: number; mtimeMs: number } +interface FileIdentity { + dev: number; + ino: number; + uid: number; + size: number; + mtimeMs: number; + ctimeMs: number; + mode: number; + nlink: number; +} -function readBoundedConfig(path: string): { source: string; identity: FileIdentity } { +interface DirectoryIdentity { + dev: number; + ino: number; + uid: number; + mode: number; + ctimeMs: number; +} + +interface StorageIdentity { + file: FileIdentity; + directory: DirectoryIdentity; +} + +function validateCanonicalPath(path: string): void { + if (typeof path !== "string" || path.length === 0 || path.trim() !== path + || path.includes("\0") || !isAbsolute(path) || normalize(path) !== path + || realpathSync(path) !== path || realpathSync(dirname(path)) !== dirname(path)) throw invalid(); +} + +function runtimeOwner(): number { + if (process.platform === "win32" || typeof process.geteuid !== "function") throw invalid(); + const owner = process.geteuid(); + if (!Number.isSafeInteger(owner) || owner < 0) throw invalid(); + return owner; +} + +function fileMetadata(info: Stats): FileIdentity { + const mode = info.mode & 0o7777; + if (!info.isFile() || info.uid !== runtimeOwner() || info.nlink !== 1 || mode !== 0o600 + || info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid(); + return { + dev: info.dev, ino: info.ino, uid: info.uid, size: info.size, + mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, mode, nlink: info.nlink, + }; +} + +function directoryMetadata(info: Stats): DirectoryIdentity { + const mode = info.mode & 0o7777; + if (!info.isDirectory() || info.uid !== runtimeOwner() || mode !== 0o700) throw invalid(); + return { dev: info.dev, ino: info.ino, uid: info.uid, mode, ctimeMs: info.ctimeMs }; +} + +function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid + && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs + && left.mode === right.mode && left.nlink === right.nlink; +} + +function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid + && left.mode === right.mode && left.ctimeMs === right.ctimeMs; +} + +function sameIdentity(left: StorageIdentity, right: StorageIdentity): boolean { + return sameFileIdentity(left.file, right.file) && sameDirectoryIdentity(left.directory, right.directory); +} + +function storageIdentity(path: string): StorageIdentity { + try { + validateCanonicalPath(path); + return { + file: fileMetadata(lstatSync(path) as Stats), + directory: directoryMetadata(lstatSync(dirname(path)) as Stats), + }; + } catch { + throw invalid(); + } +} + +function openDirectoryDescriptor(path: string): number { + return openSync(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) + | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); +} + +function readBoundedConfig(path: string): { source: string; identity: StorageIdentity } { + let directoryDescriptor: number | undefined; let fd: number | undefined; try { - fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); - const info = fstatSync(fd); - if (!info.isFile() || info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid(); + const before = storageIdentity(path); + directoryDescriptor = openDirectoryDescriptor(dirname(path)); + const openedDirectory = directoryMetadata(fstatSync(directoryDescriptor) as Stats); + if (!sameDirectoryIdentity(before.directory, openedDirectory)) throw invalid(); + fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const opened = fileMetadata(fstatSync(fd) as Stats); + if (!sameFileIdentity(before.file, opened)) throw invalid(); const buffer = Buffer.allocUnsafe(MAX_AUTH_CONFIG_BYTES + 1); - const bytesRead = readSync(fd, buffer, 0, buffer.length, 0); - if (bytesRead > MAX_AUTH_CONFIG_BYTES) throw invalid(); + let offset = 0; + while (offset < buffer.length) { + const bytesRead = readSync(fd, buffer, offset, buffer.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset > MAX_AUTH_CONFIG_BYTES) throw invalid(); + const afterFile = fileMetadata(fstatSync(fd) as Stats); + const afterPath = storageIdentity(path); + const afterOpenedDirectory = directoryMetadata(fstatSync(directoryDescriptor) as Stats); + if (!sameFileIdentity(opened, afterFile) || !sameFileIdentity(afterFile, afterPath.file) + || !sameDirectoryIdentity(before.directory, afterPath.directory) + || !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid(); + validateCanonicalPath(path); 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 }, + source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)), + identity: afterPath, }; } catch { throw invalid(); } finally { if (fd !== undefined) try { closeSync(fd); } catch { /* sanitized by design */ } + if (directoryDescriptor !== undefined) try { closeSync(directoryDescriptor); } catch { /* sanitized by design */ } } } @@ -132,8 +243,7 @@ function parseAuthenticationConfig(source: string): AuthenticationConfig { } catch { throw invalid(); } } -function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: FileIdentity } { - if (typeof path !== "string" || path.length === 0 || path.trim() !== path || path.includes("\0")) throw invalid(); +function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: StorageIdentity } { const read = readBoundedConfig(path); const value = parseAuthenticationConfig(read.source); return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity }; @@ -143,29 +253,19 @@ export function loadAuthenticationConfig(path: string): LoadedAuthConfig { return loadAuthenticationConfigWithIdentity(path).loaded; } -function fileIdentity(path: string): FileIdentity { - try { - const info = statSync(path); - if (!info.isFile()) throw invalid(); - return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs }; - } catch { throw invalid(); } -} - -function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { - return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs; -} - export function createAuthenticationConfigProvider(path: string): AuthenticationConfigProvider { - let cached: { identity: FileIdentity; loaded: LoadedAuthConfig } | undefined; + let cached: { identity: StorageIdentity; loaded: LoadedAuthConfig } | undefined; return { current(): LoadedAuthConfig { - const before = fileIdentity(path); + const before = storageIdentity(path); if (cached && sameIdentity(cached.identity, before)) return cached.loaded; for (let attempt = 0; attempt < 2; attempt += 1) { - const { loaded, identity } = loadAuthenticationConfigWithIdentity(path); - if (sameIdentity(identity, fileIdentity(path))) { - cached = { identity, loaded }; - return loaded; - } + try { + const { loaded, identity } = loadAuthenticationConfigWithIdentity(path); + if (sameIdentity(identity, storageIdentity(path))) { + cached = { identity, loaded }; + return loaded; + } + } catch { /* retry one concurrent atomic replacement, then fail closed */ } } throw invalid(); } }; diff --git a/backend/src/auth/diagnostics.ts b/backend/src/auth/diagnostics.ts index df6b3ec9..f91ccdf5 100644 --- a/backend/src/auth/diagnostics.ts +++ b/backend/src/auth/diagnostics.ts @@ -1,6 +1,7 @@ import type { AuthenticationConfigProvider, AuthMode } from "./types.js"; import type { LocalUserRegistry } from "./local-registry.js"; -import { OidcJwksUnavailableError, type OidcProtocol } from "./oidc-client.js"; +import { OidcIssuerMismatchError, OidcJwksUnavailableError, type OidcProtocol } from "./oidc-client.js"; +import { validateAuthSessionRoot } from "./session-store.js"; import type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics, GroupCatalog } from "./group-catalog.js"; export type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics } from "./group-catalog.js"; @@ -12,11 +13,11 @@ export interface AuthDiagnoser { export interface AuthDiagnoserDependencies { authMode: AuthMode; authStateRoot: string; + /** Platform integrations may inject an equivalent side-effect-free owner/ACL validator. */ + sessionRootValidator?: (root: string) => void | Promise; authentication?: AuthenticationConfigProvider; secrets?: ReadonlyMap; localUserRegistry?: LocalUserRegistry; - /** Enables a host integration to inspect a local registry without exposing user records. */ - hasEnabledLocalAdmin?: () => Promise; oidcProtocol?: OidcProtocol; groupCatalog?: GroupCatalog; } @@ -25,11 +26,6 @@ function check(code: AuthDiagnosticCode, message: string, field?: string): AuthD return { level: "error", code, message, ...(field === undefined ? {} : { field }) }; } -function sessionRootIsSafe(value: string): boolean { - return typeof value === "string" && value.startsWith("/") && value.trim() === value - && value.length > 1 && !value.includes("\0") && !/\p{Cc}/u.test(value); -} - function secretPresent(secrets: ReadonlyMap | undefined, name: string): boolean { const value = secrets?.get(name); return typeof value === "string" && value.length > 0 && value.length <= 16 * 1024 && !/\p{Cc}/u.test(value); @@ -51,13 +47,12 @@ function stableCompare(left: string, right: string): number { async function localRegistryIsUsable(deps: AuthDiagnoserDependencies): Promise { try { - if (deps.hasEnabledLocalAdmin) { - if (!await deps.hasEnabledLocalAdmin()) return check("local_admin_missing", "No enabled local administrator is configured."); - return undefined; + if (!deps.localUserRegistry) { + return check("local_user_registry_invalid", "The local user registry is unavailable."); + } + if (!await deps.localUserRegistry.hasEnabledAdmin()) { + return check("local_admin_missing", "No enabled local administrator is configured."); } - if (!deps.localUserRegistry) return check("local_user_registry_invalid", "The local user registry is unavailable."); - // The registry's safe parser refuses to load without an enabled admin; this probe never exposes users. - await deps.localUserRegistry.findByUsername("diagnostic-probe"); return undefined; } catch { return check("local_user_registry_invalid", "The local user registry is invalid."); @@ -69,7 +64,11 @@ export function createAuthDiagnoser(deps: AuthDiagnoserDependencies): AuthDiagno async inspect(options): Promise { const checks: AuthDiagnostic[] = []; const signal = options.signal ?? new AbortController().signal; - if (!sessionRootIsSafe(deps.authStateRoot)) checks.push(check("auth_session_store_invalid", "The authentication session store is invalid.")); + try { + await (deps.sessionRootValidator ?? validateAuthSessionRoot)(deps.authStateRoot); + } catch { + checks.push(check("auth_session_store_invalid", "The authentication session store is invalid.")); + } if (deps.authMode === "none" || deps.authMode === "mock") { const result = ordered(checks); @@ -122,7 +121,11 @@ export function createAuthDiagnoser(deps: AuthDiagnoserDependencies): AuthDiagno await deps.oidcProtocol.diagnose(signal); } catch (error) { checks.push(check( - error instanceof OidcJwksUnavailableError ? "oidc_jwks_unreachable" : "oidc_discovery_unreachable", + error instanceof OidcIssuerMismatchError + ? "oidc_issuer_mismatch" + : error instanceof OidcJwksUnavailableError + ? "oidc_jwks_unreachable" + : "oidc_discovery_unreachable", "The OIDC provider could not be validated.", )); } diff --git a/backend/src/auth/local-registry.ts b/backend/src/auth/local-registry.ts index 5ae615d4..364e981f 100644 --- a/backend/src/auth/local-registry.ts +++ b/backend/src/auth/local-registry.ts @@ -32,6 +32,8 @@ export interface LocalUserRecord { } export interface LocalUserRegistry { + /** Safe production diagnostic probe; never returns user records or hashes. */ + hasEnabledAdmin(): Promise; findByUsername(username: string): Promise; findBySubject(id: string): Promise; verify(user: LocalUserRecord | undefined, password: string): Promise; @@ -191,13 +193,11 @@ function parseRegistry(source: string): LocalUserRecord[] { const parsed = registrySchema.parse(document.toJSON()); const ids = new Set(); const usernames = new Set(); - let enabledAdmin = false; const records = parsed.users.map((user) => { const normalizedUsername = normalizeUsername(user.username); if (ids.has(user.id) || usernames.has(normalizedUsername)) throw invalid(); ids.add(user.id); usernames.add(normalizedUsername); - if (user.enabled && user.roles.includes("admin")) enabledAdmin = true; return Object.freeze({ id: user.id, username: user.username, @@ -209,7 +209,6 @@ function parseRegistry(source: string): LocalUserRecord[] { authRevision: user.authRevision, }); }); - if (!enabledAdmin) throw invalid(); return records; } catch { throw invalid(); @@ -241,13 +240,22 @@ export function createLocalUserRegistry(usersPath: string): LocalUserRegistry { throw invalid(); } + function operationalRecords(): LocalUserRecord[] { + const records = current(); + if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid(); + return records; + } + return { + async hasEnabledAdmin(): Promise { + return current().some((user) => user.enabled && user.roles.includes("admin")); + }, async findByUsername(username: string): Promise { const normalized = normalizeUsername(username); - return current().find((user) => user.normalizedUsername === normalized); + return operationalRecords().find((user) => user.normalizedUsername === normalized); }, async findBySubject(id: string): Promise { - return current().find((user) => user.id === id); + return operationalRecords().find((user) => user.id === id); }, async verify(user: LocalUserRecord | undefined, password: string): Promise { if (!user || !user.enabled) { diff --git a/backend/src/auth/oidc-client.ts b/backend/src/auth/oidc-client.ts index de87f04b..e7c1cd60 100644 --- a/backend/src/auth/oidc-client.ts +++ b/backend/src/auth/oidc-client.ts @@ -48,6 +48,14 @@ export class OidcJwksUnavailableError extends OidcProtocolError { } } +/** Discovery completed with metadata for a different issuer than the configured trust anchor. */ +export class OidcIssuerMismatchError extends OidcProtocolError { + constructor() { + super("oidc_issuer_mismatch"); + this.name = "OidcIssuerMismatchError"; + } +} + export interface OidcProtocolOptions { issuer: string; clientId: string; @@ -373,7 +381,14 @@ function availabilityFailure(error: unknown): boolean { } function protocolFailure(error: unknown): OidcProtocolError { - if (error instanceof OidcProtocolError) return error; + let current = error; + const seen = new Set(); + for (let depth = 0; depth < 8; depth += 1) { + if (current instanceof OidcProtocolError) return current; + if (!current || typeof current !== "object" || seen.has(current)) break; + seen.add(current); + current = (current as { cause?: unknown }).cause; + } return availabilityFailure(error) ? new OidcProviderUnavailableError() : new OidcProtocolError(); } @@ -463,22 +478,40 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol { const configuration = async (): Promise => { if (!discovered) { discovered = (async () => { + let inspectingDiscovery = true; + const issuerCheckingFetch: CustomFetch = async (input, init) => { + const response = await transport.customFetch(input, init); + if (inspectingDiscovery) { + try { + const metadata = await response.clone().json() as { issuer?: unknown }; + if (typeof metadata?.issuer === "string" && metadata.issuer !== options.issuer) { + throw new OidcIssuerMismatchError(); + } + } catch (error) { + if (error instanceof OidcIssuerMismatchError) throw error; + // The OIDC library owns all other discovery-document validation. + } + } + return response; + }; try { const config = await discovery( issuerUrl, options.clientId, { client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] }, undefined, - { [customFetch]: transport.customFetch, timeout: httpTimeoutMs / 1000 }, + { [customFetch]: issuerCheckingFetch, timeout: httpTimeoutMs / 1000 }, ); const metadata = config.serverMetadata(); - if (metadata.issuer !== options.issuer) throw new OidcProtocolError(); + if (metadata.issuer !== options.issuer) throw new OidcIssuerMismatchError(); httpsEndpoint(metadata.authorization_endpoint); httpsEndpoint(metadata.token_endpoint); httpsEndpoint(metadata.jwks_uri); return config; } catch (error) { throw protocolFailure(error); + } finally { + inspectingDiscovery = false; } })(); } diff --git a/backend/src/auth/session-store.ts b/backend/src/auth/session-store.ts index 60aab608..229d0dbe 100644 --- a/backend/src/auth/session-store.ts +++ b/backend/src/auth/session-store.ts @@ -399,13 +399,27 @@ function privateDirectory(path: string): void { } } +function validateSessionRootSyntax(root: string): void { + if (process.platform === "win32" || typeof root !== "string" || root.length === 0 + || root.includes("\0") || /\p{Cc}/u.test(root) || !isAbsolute(root) || normalize(root) !== root) throw invalid(); +} + +/** Side-effect-free POSIX validator shared by runtime storage and static diagnostics. */ +export function validateAuthSessionRoot(root: string): void { + try { + validateSessionRootSyntax(root); + directoryIdentity(root); + } catch { + throw invalid(); + } +} + function storageDirectories(root: string): StorageDirectories { // Native Windows calls must dispatch to the tht DACL-capable bridge before reaching this // POSIX-only helper. Keep this guard so an un-routed caller cannot fall back to chmod. - if (process.platform === "win32") throw invalid(); - if (typeof root !== "string" || root.length === 0 || root.includes("\0") - || !isAbsolute(root) || normalize(root) !== root) throw invalid(); + validateSessionRootSyntax(root); privateDirectory(root); + validateAuthSessionRoot(root); const sessions = join(root, "sessions"); const oidc = join(root, "oidc"); privateDirectory(sessions); diff --git a/backend/test/app-auth-mode.test.ts b/backend/test/app-auth-mode.test.ts index efaf958d..31d03fa3 100644 --- a/backend/test/app-auth-mode.test.ts +++ b/backend/test/app-auth-mode.test.ts @@ -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 { diff --git a/backend/test/auth-config.test.ts b/backend/test/auth-config.test.ts index 7c78767c..59eb7506 100644 --- a/backend/test/auth-config.test.ts +++ b/backend/test/auth-config.test.ts @@ -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"); diff --git a/backend/test/auth-diagnostics.test.ts b/backend/test/auth-diagnostics.test.ts index 45278e2e..132b7239 100644 --- a/backend/test/auth-diagnostics.test.ts +++ b/backend/test/auth-diagnostics.test.ts @@ -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(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(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); +}); diff --git a/backend/test/auth-session-store.test.ts b/backend/test/auth-session-store.test.ts index cc3e22c4..78e8a545 100644 --- a/backend/test/auth-session-store.test.ts +++ b/backend/test/auth-session-store.test.ts @@ -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); diff --git a/backend/test/authentik-group-catalog.test.ts b/backend/test/authentik-group-catalog.test.ts index 0bb5ccac..fab5e74f 100644 --- a/backend/test/authentik-group-catalog.test.ts +++ b/backend/test/authentik-group-catalog.test.ts @@ -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(() => { /* cancellation remains advisory */ }); + }, + }); + const completion = catalog(vi.fn(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(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 { diff --git a/backend/test/config.test.ts b/backend/test/config.test.ts index c0ae26da..1a89029a 100644 --- a/backend/test/config.test.ts +++ b/backend/test/config.test.ts @@ -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"); diff --git a/backend/test/local-registry.test.ts b/backend/test/local-registry.test.ts index 004edcb2..d1c74c6e 100644 --- a/backend/test/local-registry.test.ts +++ b/backend/test/local-registry.test.ts @@ -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"'))], diff --git a/backend/test/oidc-client.test.ts b/backend/test/oidc-client.test.ts index 88580a2a..7cd2b3b3 100644 --- a/backend/test/oidc-client.test.ts +++ b/backend/test/oidc-client.test.ts @@ -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([