fix(auth): make diagnostics match runtime safety

This commit is contained in:
2026-08-17 11:23:52 +02:00
parent f0ae680671
commit 7cfbee36fa
14 changed files with 619 additions and 99 deletions
+12 -6
View File
@@ -107,7 +107,10 @@ function validContentLength(response: Response): boolean {
} }
async function readBounded(response: Response, signal: AbortSignal): Promise<Uint8Array | undefined> { async function readBounded(response: Response, signal: AbortSignal): Promise<Uint8Array | undefined> {
if (!validContentLength(response)) return undefined; if (!validContentLength(response)) {
cancelResponse(response);
return undefined;
}
const reader = response.body?.getReader(); const reader = response.body?.getReader();
if (!reader) return new Uint8Array(); if (!reader) return new Uint8Array();
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
@@ -144,12 +147,15 @@ function exactResult(name: string, parsed: unknown): GroupResult {
|| Array.isArray(record.pagination)) return "unreachable"; || Array.isArray(record.pagination)) return "unreachable";
const next = (record.pagination as { next?: unknown }).next; const next = (record.pagination as { next?: unknown }).next;
if (next !== null && next !== undefined) return "ambiguous"; if (next !== null && next !== undefined) return "ambiguous";
if (record.results.length === 0) return "missing"; const resultNames: string[] = [];
if (record.results.length !== 1) return "ambiguous"; for (const result of record.results) {
const result = record.results[0];
if (!result || typeof result !== "object" || Array.isArray(result) if (!result || typeof result !== "object" || Array.isArray(result)
|| (result as { name?: unknown }).name !== name) return "missing"; || typeof (result as { name?: unknown }).name !== "string") return "unreachable";
return "present"; 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 { export function createAuthentikGroupCatalog(options: AuthentikGroupCatalogOptions): GroupCatalog {
+127 -27
View File
@@ -1,5 +1,15 @@
import { createHash } from "node:crypto"; 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 { parseDocument } from "yaml";
import { z } from "zod"; import { z } from "zod";
import type { import type {
@@ -60,25 +70,126 @@ const oidcSchema = z.strictObject({
authorization: z.strictObject({ groupRoles: groupRolesSchema }), 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; let fd: number | undefined;
try { try {
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); const before = storageIdentity(path);
const info = fstatSync(fd); directoryDescriptor = openDirectoryDescriptor(dirname(path));
if (!info.isFile() || info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid(); 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 buffer = Buffer.allocUnsafe(MAX_AUTH_CONFIG_BYTES + 1);
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0); let offset = 0;
if (bytesRead > MAX_AUTH_CONFIG_BYTES) throw invalid(); 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 { return {
source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)), source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)),
identity: { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs }, identity: afterPath,
}; };
} catch { } catch {
throw invalid(); throw invalid();
} finally { } finally {
if (fd !== undefined) try { closeSync(fd); } catch { /* sanitized by design */ } 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(); } } catch { throw invalid(); }
} }
function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: FileIdentity } { function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: StorageIdentity } {
if (typeof path !== "string" || path.length === 0 || path.trim() !== path || path.includes("\0")) throw invalid();
const read = readBoundedConfig(path); const read = readBoundedConfig(path);
const value = parseAuthenticationConfig(read.source); const value = parseAuthenticationConfig(read.source);
return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity }; 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; 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 { export function createAuthenticationConfigProvider(path: string): AuthenticationConfigProvider {
let cached: { identity: FileIdentity; loaded: LoadedAuthConfig } | undefined; let cached: { identity: StorageIdentity; loaded: LoadedAuthConfig } | undefined;
return { current(): LoadedAuthConfig { return { current(): LoadedAuthConfig {
const before = fileIdentity(path); const before = storageIdentity(path);
if (cached && sameIdentity(cached.identity, before)) return cached.loaded; if (cached && sameIdentity(cached.identity, before)) return cached.loaded;
for (let attempt = 0; attempt < 2; attempt += 1) { for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const { loaded, identity } = loadAuthenticationConfigWithIdentity(path); const { loaded, identity } = loadAuthenticationConfigWithIdentity(path);
if (sameIdentity(identity, fileIdentity(path))) { if (sameIdentity(identity, storageIdentity(path))) {
cached = { identity, loaded }; cached = { identity, loaded };
return loaded; return loaded;
} }
} catch { /* retry one concurrent atomic replacement, then fail closed */ }
} }
throw invalid(); throw invalid();
} }; } };
+19 -16
View File
@@ -1,6 +1,7 @@
import type { AuthenticationConfigProvider, AuthMode } from "./types.js"; import type { AuthenticationConfigProvider, AuthMode } from "./types.js";
import type { LocalUserRegistry } from "./local-registry.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"; import type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics, GroupCatalog } from "./group-catalog.js";
export type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics } from "./group-catalog.js"; export type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics } from "./group-catalog.js";
@@ -12,11 +13,11 @@ export interface AuthDiagnoser {
export interface AuthDiagnoserDependencies { export interface AuthDiagnoserDependencies {
authMode: AuthMode; authMode: AuthMode;
authStateRoot: string; authStateRoot: string;
/** Platform integrations may inject an equivalent side-effect-free owner/ACL validator. */
sessionRootValidator?: (root: string) => void | Promise<void>;
authentication?: AuthenticationConfigProvider; authentication?: AuthenticationConfigProvider;
secrets?: ReadonlyMap<string, string>; secrets?: ReadonlyMap<string, string>;
localUserRegistry?: LocalUserRegistry; localUserRegistry?: LocalUserRegistry;
/** Enables a host integration to inspect a local registry without exposing user records. */
hasEnabledLocalAdmin?: () => Promise<boolean>;
oidcProtocol?: OidcProtocol; oidcProtocol?: OidcProtocol;
groupCatalog?: GroupCatalog; groupCatalog?: GroupCatalog;
} }
@@ -25,11 +26,6 @@ function check(code: AuthDiagnosticCode, message: string, field?: string): AuthD
return { level: "error", code, message, ...(field === undefined ? {} : { field }) }; 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<string, string> | undefined, name: string): boolean { function secretPresent(secrets: ReadonlyMap<string, string> | undefined, name: string): boolean {
const value = secrets?.get(name); const value = secrets?.get(name);
return typeof value === "string" && value.length > 0 && value.length <= 16 * 1024 && !/\p{Cc}/u.test(value); 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<AuthDiagnostic | undefined> { async function localRegistryIsUsable(deps: AuthDiagnoserDependencies): Promise<AuthDiagnostic | undefined> {
try { try {
if (deps.hasEnabledLocalAdmin) { if (!deps.localUserRegistry) {
if (!await deps.hasEnabledLocalAdmin()) return check("local_admin_missing", "No enabled local administrator is configured."); return check("local_user_registry_invalid", "The local user registry is unavailable.");
return undefined; }
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; return undefined;
} catch { } catch {
return check("local_user_registry_invalid", "The local user registry is invalid."); 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<AuthDiagnostics> { async inspect(options): Promise<AuthDiagnostics> {
const checks: AuthDiagnostic[] = []; const checks: AuthDiagnostic[] = [];
const signal = options.signal ?? new AbortController().signal; 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") { if (deps.authMode === "none" || deps.authMode === "mock") {
const result = ordered(checks); const result = ordered(checks);
@@ -122,7 +121,11 @@ export function createAuthDiagnoser(deps: AuthDiagnoserDependencies): AuthDiagno
await deps.oidcProtocol.diagnose(signal); await deps.oidcProtocol.diagnose(signal);
} catch (error) { } catch (error) {
checks.push(check( 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.", "The OIDC provider could not be validated.",
)); ));
} }
+13 -5
View File
@@ -32,6 +32,8 @@ export interface LocalUserRecord {
} }
export interface LocalUserRegistry { export interface LocalUserRegistry {
/** Safe production diagnostic probe; never returns user records or hashes. */
hasEnabledAdmin(): Promise<boolean>;
findByUsername(username: string): Promise<LocalUserRecord | undefined>; findByUsername(username: string): Promise<LocalUserRecord | undefined>;
findBySubject(id: string): Promise<LocalUserRecord | undefined>; findBySubject(id: string): Promise<LocalUserRecord | undefined>;
verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>; verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>;
@@ -191,13 +193,11 @@ function parseRegistry(source: string): LocalUserRecord[] {
const parsed = registrySchema.parse(document.toJSON()); const parsed = registrySchema.parse(document.toJSON());
const ids = new Set<string>(); const ids = new Set<string>();
const usernames = new Set<string>(); const usernames = new Set<string>();
let enabledAdmin = false;
const records = parsed.users.map((user) => { const records = parsed.users.map((user) => {
const normalizedUsername = normalizeUsername(user.username); const normalizedUsername = normalizeUsername(user.username);
if (ids.has(user.id) || usernames.has(normalizedUsername)) throw invalid(); if (ids.has(user.id) || usernames.has(normalizedUsername)) throw invalid();
ids.add(user.id); ids.add(user.id);
usernames.add(normalizedUsername); usernames.add(normalizedUsername);
if (user.enabled && user.roles.includes("admin")) enabledAdmin = true;
return Object.freeze({ return Object.freeze({
id: user.id, id: user.id,
username: user.username, username: user.username,
@@ -209,7 +209,6 @@ function parseRegistry(source: string): LocalUserRecord[] {
authRevision: user.authRevision, authRevision: user.authRevision,
}); });
}); });
if (!enabledAdmin) throw invalid();
return records; return records;
} catch { } catch {
throw invalid(); throw invalid();
@@ -241,13 +240,22 @@ export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
throw invalid(); throw invalid();
} }
function operationalRecords(): LocalUserRecord[] {
const records = current();
if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid();
return records;
}
return { return {
async hasEnabledAdmin(): Promise<boolean> {
return current().some((user) => user.enabled && user.roles.includes("admin"));
},
async findByUsername(username: string): Promise<LocalUserRecord | undefined> { async findByUsername(username: string): Promise<LocalUserRecord | undefined> {
const normalized = normalizeUsername(username); const normalized = normalizeUsername(username);
return current().find((user) => user.normalizedUsername === normalized); return operationalRecords().find((user) => user.normalizedUsername === normalized);
}, },
async findBySubject(id: string): Promise<LocalUserRecord | undefined> { async findBySubject(id: string): Promise<LocalUserRecord | undefined> {
return current().find((user) => user.id === id); return operationalRecords().find((user) => user.id === id);
}, },
async verify(user: LocalUserRecord | undefined, password: string): Promise<boolean> { async verify(user: LocalUserRecord | undefined, password: string): Promise<boolean> {
if (!user || !user.enabled) { if (!user || !user.enabled) {
+36 -3
View File
@@ -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 { export interface OidcProtocolOptions {
issuer: string; issuer: string;
clientId: string; clientId: string;
@@ -373,7 +381,14 @@ function availabilityFailure(error: unknown): boolean {
} }
function protocolFailure(error: unknown): OidcProtocolError { function protocolFailure(error: unknown): OidcProtocolError {
if (error instanceof OidcProtocolError) return error; let current = error;
const seen = new Set<object>();
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(); return availabilityFailure(error) ? new OidcProviderUnavailableError() : new OidcProtocolError();
} }
@@ -463,22 +478,40 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
const configuration = async (): Promise<Configuration> => { const configuration = async (): Promise<Configuration> => {
if (!discovered) { if (!discovered) {
discovered = (async () => { 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 { try {
const config = await discovery( const config = await discovery(
issuerUrl, issuerUrl,
options.clientId, options.clientId,
{ client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] }, { client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] },
undefined, undefined,
{ [customFetch]: transport.customFetch, timeout: httpTimeoutMs / 1000 }, { [customFetch]: issuerCheckingFetch, timeout: httpTimeoutMs / 1000 },
); );
const metadata = config.serverMetadata(); 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.authorization_endpoint);
httpsEndpoint(metadata.token_endpoint); httpsEndpoint(metadata.token_endpoint);
httpsEndpoint(metadata.jwks_uri); httpsEndpoint(metadata.jwks_uri);
return config; return config;
} catch (error) { } catch (error) {
throw protocolFailure(error); throw protocolFailure(error);
} finally {
inspectingDiscovery = false;
} }
})(); })();
} }
+17 -3
View File
@@ -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 { function storageDirectories(root: string): StorageDirectories {
// Native Windows calls must dispatch to the tht DACL-capable bridge before reaching this // 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. // POSIX-only helper. Keep this guard so an un-routed caller cannot fall back to chmod.
if (process.platform === "win32") throw invalid(); validateSessionRootSyntax(root);
if (typeof root !== "string" || root.length === 0 || root.includes("\0")
|| !isAbsolute(root) || normalize(root) !== root) throw invalid();
privateDirectory(root); privateDirectory(root);
validateAuthSessionRoot(root);
const sessions = join(root, "sessions"); const sessions = join(root, "sessions");
const oidc = join(root, "oidc"); const oidc = join(root, "oidc");
privateDirectory(sessions); privateDirectory(sessions);
+5 -3
View File
@@ -1,5 +1,5 @@
import { expect, test } from "vitest"; 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { stringify } from "yaml"; import { stringify } from "yaml";
@@ -7,7 +7,8 @@ import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js"; import { loadConfig } from "../src/config.js";
test("configured OIDC advertises login but fails closed without its runtime client secret", async () => { 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"); const file = join(directory, "auth.yaml");
writeFileSync(file, stringify({ writeFileSync(file, stringify({
version: 1, mode: "oidc", publicUrl: "https://thothii.example.org", 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" }, groupCatalog: { driver: "authentik", baseUrl: "https://authentik.example.org", apiTokenRef: "THT_AUTHENTIK_API_TOKEN" },
authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } }, authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } },
}), "utf8"); }), { encoding: "utf8", mode: 0o600 });
chmodSync(file, 0o600);
try { try {
const app = buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: join(directory, "auth-state") })); const app = buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: join(directory, "auth-state") }));
try { try {
+97 -6
View File
@@ -1,8 +1,18 @@
import { afterEach, expect, test, vi } from "vitest"; 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 { createHash } from "node:crypto";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { dirname, join } from "node:path";
import { stringify } from "yaml"; import { stringify } from "yaml";
import { import {
createAuthenticationConfigProvider, createAuthenticationConfigProvider,
@@ -33,10 +43,12 @@ afterEach(() => {
}); });
function writeFixture(value: unknown): string { 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); directories.push(directory);
const file = join(directory, "auth.yaml"); const file = join(directory, "auth.yaml");
writeFileSync(file, stringify(value), "utf8"); writeFileSync(file, stringify(value), { encoding: "utf8", mode: 0o600 });
chmodSync(file, 0o600);
return file; return file;
} }
@@ -214,7 +226,8 @@ test("provider reloads after an atomic configuration replacement", () => {
const provider = createAuthenticationConfigProvider(file); const provider = createAuthenticationConfigProvider(file);
const original = provider.current(); const original = provider.current();
const replacement = `${file}.replacement`; 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); renameSync(replacement, file);
const reloaded = provider.current(); 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", () => { test("provider retries when replacement occurs between its read and cache identity check", () => {
const file = writeFixture(localConfig()); const file = writeFixture(localConfig());
const replacement = `${file}.replacement`; 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); const provider = createAuthenticationConfigProvider(file);
readHook.callback = () => renameSync(replacement, file); readHook.callback = () => renameSync(replacement, file);
expect(provider.current().value.publicUrl).toBe("http://127.0.0.1:9999"); 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", () => { 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");
+177 -16
View File
@@ -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 { 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"; import type { LoadedAuthConfig } from "../src/auth/types.js";
const sentinels = [ const sentinels = [
"oidc-client-secret-UNIQUE-7P3", "authentik-api-token-UNIQUE-9Q7", "oidc-client-secret-UNIQUE-7P3", "authentik-api-token-UNIQUE-9Q7",
"cookie-UNIQUE-5M1", "$argon2id$v=19$password-hash-UNIQUE-2T8", "/private/path-UNIQUE-4K6", "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 { function oidcConfig(): LoadedAuthConfig {
return { 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 () => { test("reports deterministic live OIDC checks and silently ignores unrelated groups", async () => {
const oidcDiagnose = vi.fn(async () => undefined); const oidcDiagnose = vi.fn(async () => undefined);
const groupCatalog = { verifyConfiguredGroups: vi.fn(async (names: readonly string[]) => { const fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
expect(names).toEqual(["TOT Admin", "TOT Users"]); const requested = new URL(String(input)).searchParams.get("name");
return []; 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({ const report = await createAuthDiagnoser({
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state", 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]!]]), secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
oidcProtocol: { diagnose: oidcDiagnose }, groupCatalog, oidcProtocol: { diagnose: oidcDiagnose }, groupCatalog,
}).inspect({ live: true }); }).inspect({ live: true });
expect(report).toEqual({ ready: true, mode: "oidc", checks: [expect.objectContaining({ level: "info", code: "auth_ready" })] }); expect(report).toEqual({ ready: true, mode: "oidc", checks: [expect.objectContaining({ level: "info", code: "auth_ready" })] });
expect(oidcDiagnose).toHaveBeenCalledOnce(); 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(report.checks).not.toContainEqual(expect.objectContaining({ level: "warning" }));
expect(JSON.stringify(report)).not.toContain("Unmapped Corporate Group"); 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 () => { test("requires both fixed OIDC and Authentik secrets only in OIDC mode", async () => {
const oidc = createAuthDiagnoser({ const oidc = createAuthDiagnoser({
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state", secrets: new Map(), authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state", secrets: new Map(),
sessionRootValidator: acceptSessionRoot,
}); });
const local = createAuthDiagnoser({ const local = createAuthDiagnoser({
authMode: "local", authStateRoot: "/safe/auth-state", secrets: new Map(), authMode: "local", authStateRoot: "/safe/auth-state", secrets: new Map(),
authentication: { current: () => ({ sessionRootValidator: acceptSessionRoot,
revision: "b".repeat(64), sourcePath: "/safe/auth.yaml", authentication: { current: () => localConfig("/safe/auth.yaml") },
value: { localUserRegistry: { hasEnabledAdmin: async () => false } as never,
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,
}); });
await expect(oidc.inspect({ live: false })).resolves.toMatchObject({ ready: false, checks: [ 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 () => { test("maps OIDC and group catalog failures to only the closed diagnostics code union", async () => {
const report = await createAuthDiagnoser({ const report = await createAuthDiagnoser({
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state", 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]!]]), secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
oidcProtocol: { diagnose: async () => { throw new Error("issuer unavailable"); } }, 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" }] }, 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 () => { test("reports a JWKS validation failure through its closed diagnostic code", async () => {
const report = await createAuthDiagnoser({ const report = await createAuthDiagnoser({
authMode: "oidc", authentication: { current: oidcConfig }, authStateRoot: "/safe/auth-state", 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]!]]), secrets: new Map([["THT_OIDC_CLIENT_SECRET", sentinels[0]!], ["THT_AUTHENTIK_API_TOKEN", sentinels[1]!]]),
oidcProtocol: { diagnose: async () => { throw new OidcJwksUnavailableError(); } }, oidcProtocol: { diagnose: async () => { throw new OidcJwksUnavailableError(); } },
groupCatalog: { verifyConfiguredGroups: async () => [] }, 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" })]); 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 () => { test("redacts exceptional configuration, registry, protocol, and catalog errors", async () => {
const detail = sentinels.join(" "); const detail = sentinels.join(" ");
const report = await createAuthDiagnoser({ 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); for (const sentinel of sentinels) expect(rendered).not.toContain(sentinel);
expect(report.checks.every((check) => check.level === "error" || check.level === "info")).toBe(true); 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);
});
+37 -1
View File
@@ -18,7 +18,7 @@ import {
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { basename, join } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
const fsHooks = vi.hoisted(() => ({ const fsHooks = vi.hoisted(() => ({
@@ -66,6 +66,7 @@ vi.mock("node:fs", async (importOriginal) => {
import { import {
createFileAuthSessionStore, createFileAuthSessionStore,
deriveCsrfToken, deriveCsrfToken,
validateAuthSessionRoot,
type AuthSessionStore, type AuthSessionStore,
type FileAuthSessionStoreOptions, type FileAuthSessionStoreOptions,
type SessionCreateInput, type SessionCreateInput,
@@ -241,6 +242,41 @@ async function isolatedOidcCreator(storageRoot: string, attempts: number): Promi
} }
describe("file-backed auth session store", () => { 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 () => { test("fails closed and revokes a session when constructed without validity dependencies", async () => {
const storageRoot = root(); const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot); 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 () => { test("aborts a hanging request at five seconds", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
+6 -4
View File
@@ -1,14 +1,16 @@
import { expect, test } from "vitest"; 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { stringify } from "yaml"; import { stringify } from "yaml";
import { loadConfig } from "../src/config.js"; import { loadConfig } from "../src/config.js";
function authFile(value: unknown): { directory: string; file: string } { 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"); 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 }; 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", () => { 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 { try {
expect(() => loadConfig({ THT_AUTH_CONFIG_FILE: directory })) expect(() => loadConfig({ THT_AUTH_CONFIG_FILE: directory }))
.toThrow("authentication configuration is invalid"); .toThrow("authentication configuration is invalid");
+12 -1
View File
@@ -125,6 +125,18 @@ describe("local user registry", () => {
await expect(registry.verify(await registry.findByUsername("operator"), password)).resolves.toBe(false); 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 () => { test("rejects a valid registry under a non-private authentication directory", async () => {
const fixture = writeRegistry(registryYaml(userYaml())); const fixture = writeRegistry(registryYaml(userYaml()));
chmodSync(fixture.root, 0o750); chmodSync(fixture.root, 0o750);
@@ -151,7 +163,6 @@ describe("local user registry", () => {
["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))], ["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))],
["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))], ["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))],
["unknown YAML fields", `${registryYaml(userYaml())}unexpected: true\n`], ["unknown YAML fields", `${registryYaml(userYaml())}unexpected: true\n`],
["no enabled admin", registryYaml(userYaml({ role: "user" }))],
["duplicate roles", registryYaml(userYaml().replace(" - admin", " - admin\n - admin"))], ["duplicate roles", registryYaml(userYaml().replace(" - admin", " - admin\n - admin"))],
["invalid password hash", registryYaml(userYaml().replace(passwordHash, "not-a-password-hash"))], ["invalid password hash", registryYaml(userYaml().replace(passwordHash, "not-a-password-hash"))],
["control character in display name", registryYaml(userYaml().replace("displayName: Admin", 'displayName: "Admin\\tUser"'))], ["control character in display name", registryYaml(userYaml().replace("displayName: Admin", 'displayName: "Admin\\tUser"'))],
+15 -3
View File
@@ -1,6 +1,12 @@
import { createSign, generateKeyPairSync } from "node:crypto"; import { createSign, generateKeyPairSync } from "node:crypto";
import { expect, test } from "vitest"; 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 issuer = "https://issuer.example.test";
const clientId = "thothii"; 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, issuer: "http://issuer.example.test", clientId, clientSecret: "secret", callbackUrl,
scopes: ["openid"], groupsClaim: "groups", scopes: ["openid"], groupsClaim: "groups",
})).toThrow(OidcProtocolError); })).toThrow(OidcProtocolError);
await expect(protocol({ discoveryIssuer: "https://other.example.test" }).authorizationUrl({ state, nonce, codeVerifier: verifier })) try {
.rejects.toThrow(OidcProtocolError); 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([ test.each([