diff --git a/api/src/app.ts b/api/src/app.ts index ff92f80d..6e9f36a7 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -17,6 +17,7 @@ import { } from "./middleware/auth"; import { createAuth } from "./auth/config"; import { createRequireAdminMiddleware } from "./authorization"; +import { configureAuthLogHashKey, safeLogAuthEvent } from "./auth-logger"; const DEFAULT_MAX_ASSET_SIZE = 5 * 1024 * 1024; // 5MB const DEFAULT_MAX_LAYOUT_SIZE = 1 * 1024 * 1024; // 1MB @@ -37,6 +38,7 @@ type AppEnv = { export function createApp(env: EnvMap = process.env): Hono { const app = new Hono(); const securityConfig = resolveApiSecurityConfig(env); + configureAuthLogHashKey(securityConfig.authLogHashKey); if (securityConfig.isProduction && securityConfig.allowInsecureCors) { console.warn( diff --git a/api/src/auth-logger.test.ts b/api/src/auth-logger.test.ts new file mode 100644 index 00000000..8fe99d01 --- /dev/null +++ b/api/src/auth-logger.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { + configureAuthLogHashKey, + emitAuthEvent, + extractRequestContext, + pseudonymizeIdentifier, + redactHeaders, + resetAuthLogHashConfigForTests, + type AuthEvent, +} from "./auth-logger"; +import { createApp } from "./app"; +import { + clearInvalidatedAuthSessions, + createSignedAuthSessionToken, + type AuthSessionClaimsInput, + type EnvMap, +} from "./security"; + +const TEST_AUTH_SECRET = "rackula-auth-session-secret-for-tests-0123456789"; + +function buildEnv(overrides: EnvMap = {}): EnvMap { + return { NODE_ENV: "test", ...overrides }; +} + +function buildAuthEnabledEnv(overrides: EnvMap = {}): EnvMap { + return buildEnv({ + RACKULA_AUTH_MODE: "oidc", + RACKULA_AUTH_SESSION_SECRET: TEST_AUTH_SECRET, + CORS_ORIGIN: "https://rack.example.com", + RACKULA_AUTH_SESSION_MAX_AGE_SECONDS: "3600", + RACKULA_AUTH_SESSION_IDLE_TIMEOUT_SECONDS: "300", + ...overrides, + }); +} + +// Default cookie carries role: "admin" for passing authorization checks. +function buildAuthCookie( + overrides: Partial = {}, +): string { + const now = Math.floor(Date.now() / 1000); + const token = createSignedAuthSessionToken( + { + sub: "admin@example.com", + sid: "session-default", + role: "admin", + iat: now - 30, + exp: now + 600, + idleExp: now + 120, + generation: 0, + ...overrides, + }, + TEST_AUTH_SECRET, + { + sessionMaxAgeSeconds: 3600, + sessionIdleTimeoutSeconds: 300, + sessionGeneration: 0, + }, + ); + return `rackula_auth_session=${token}`; +} + +beforeEach(() => { + clearInvalidatedAuthSessions(); + resetAuthLogHashConfigForTests(); + configureAuthLogHashKey("rackula-auth-log-test-key"); +}); + +describe("redactHeaders", () => { + it("redacts sensitive headers", () => { + const result = redactHeaders({ + "Content-Type": "application/json", + Authorization: "Bearer secret-token", + Cookie: "session=abc123", + "Set-Cookie": "session=xyz", + "X-Forwarded-For": "192.168.1.1", + "X-Request-Id": "req-123", + }); + + expect(result["Content-Type"]).toBe("application/json"); + expect(result["Authorization"]).toBe("[REDACTED]"); + expect(result["Cookie"]).toBe("[REDACTED]"); + expect(result["Set-Cookie"]).toBe("[REDACTED]"); + expect(result["X-Forwarded-For"]).toBe("[REDACTED]"); + expect(result["X-Request-Id"]).toBe("req-123"); + }); +}); + +describe("extractRequestContext", () => { + it("extracts method, path, and IP from request", () => { + const request = new Request("https://example.com/api/layouts/abc", { + method: "PUT", + headers: { "X-Real-IP": "10.0.0.1" }, + }); + + const ctx = extractRequestContext(request); + expect(ctx.method).toBe("PUT"); + expect(ctx.path).toBe("/api/layouts/abc"); + expect(ctx.ip).toBe("10.0.0.1"); + }); + + it("returns undefined IP when header is missing", () => { + const request = new Request("https://example.com/health"); + const ctx = extractRequestContext(request); + expect(ctx.ip).toBeUndefined(); + }); +}); + +describe("emitAuthEvent", () => { + let writeSpy: ReturnType; + + beforeEach(() => { + writeSpy = spyOn(process.stdout, "write").mockImplementation(() => true); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it("writes JSON line to stdout with pseudonymized identifiers", () => { + + const event: AuthEvent = { + timestamp: "2026-02-19T10:00:00.000Z", + event: "auth.logout", + subject: "user@example.com", + ip: "10.0.0.9", + method: "POST", + path: "/auth/logout", + }; + + emitAuthEvent(event); + + expect(writeSpy).toHaveBeenCalledTimes(1); + const output = writeSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output.trim()); + expect(parsed.event).toBe("auth.logout"); + expect(parsed.subject).toBe( + pseudonymizeIdentifier("user@example.com", "subject"), + ); + expect(parsed.ip).toBe(pseudonymizeIdentifier("10.0.0.9", "ip")); + expect(parsed.timestamp).toBe("2026-02-19T10:00:00.000Z"); + }); + + it("never includes raw tokens or session IDs in output", () => { + emitAuthEvent({ + timestamp: new Date().toISOString(), + event: "auth.session.invalid", + reason: "expired session", + method: "GET", + path: "/api/layouts", + }); + + const output = writeSpy.mock.calls[0][0] as string; + // Verify no common secret patterns leak + expect(output).not.toContain("Bearer"); + expect(output).not.toContain("rackula_auth_session="); + }); +}); + +describe("auth event integration", () => { + let writeSpy: ReturnType; + + beforeEach(() => { + writeSpy = spyOn(process.stdout, "write").mockImplementation(() => true); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + function extractAuthEvents(spy: ReturnType): Array> { + return spy.mock.calls + .map((call) => { + try { + return JSON.parse((call[0] as string).trim()); + } catch { + return null; + } + }) + .filter((e): e is Record => + typeof e?.event === "string" && (e.event as string).startsWith("auth."), + ); + } + + it("logs auth.session.invalid when anonymous request hits auth gate", async () => { + const app = createApp(buildAuthEnabledEnv()); + + await app.request("/api/layouts"); + + const authEvents = extractAuthEvents(writeSpy); + + expect(authEvents.some((e) => e.event === "auth.session.invalid")).toBe(true); + const event = authEvents.find((e) => e.event === "auth.session.invalid"); + expect(event).toBeDefined(); + expect(event!.reason).toBe("missing or invalid session cookie"); + expect(event!.path).toBe("/api/layouts"); + }); + + it("logs auth.logout on successful logout", async () => { + const app = createApp(buildAuthEnabledEnv()); + + await app.request("/auth/logout", { + method: "POST", + headers: { + Cookie: buildAuthCookie({ sid: "logout-log-session" }), + Origin: "https://rack.example.com", + }, + }); + + const authEvents = extractAuthEvents(writeSpy); + + expect(authEvents.some((e) => e.event === "auth.logout")).toBe(true); + const event = authEvents.find((e) => e.event === "auth.logout"); + expect(event).toBeDefined(); + expect(event!.subject).toBe( + pseudonymizeIdentifier("admin@example.com", "subject"), + ); + }); + + it("logs auth.denied when non-admin attempts write", async () => { + const app = createApp(buildAuthEnabledEnv()); + + await app.request("/layouts/not-a-uuid", { + method: "PUT", + headers: { + Cookie: buildAuthCookie({ role: "viewer", sid: "viewer-denied" }), + Origin: "https://rack.example.com", + "Content-Type": "text/plain", + }, + body: "version: 1.0.0", + }); + + const authEvents = extractAuthEvents(writeSpy); + + expect(authEvents.some((e) => e.event === "auth.denied")).toBe(true); + const event = authEvents.find((e) => e.event === "auth.denied"); + expect(event).toBeDefined(); + expect(event!.subject).toBe( + pseudonymizeIdentifier("admin@example.com", "subject"), + ); + expect(event!.reason).toContain("viewer"); + }); + + it("logs auth.login.success on valid auth check", async () => { + const app = createApp(buildAuthEnabledEnv()); + + await app.request("/auth/check", { + headers: { + Cookie: buildAuthCookie({ sid: "check-success" }), + Origin: "https://rack.example.com", + }, + }); + + const authEvents = extractAuthEvents(writeSpy); + + expect(authEvents.some((e) => e.event === "auth.login.success")).toBe(true); + const event = authEvents.find((e) => e.event === "auth.login.success"); + expect(event).toBeDefined(); + expect(event!.subject).toBe( + pseudonymizeIdentifier("admin@example.com", "subject"), + ); + }); + + it("logs auth.login.failure on invalid auth check", async () => { + const app = createApp(buildAuthEnabledEnv()); + + await app.request("/auth/check"); + + const authEvents = extractAuthEvents(writeSpy); + + expect(authEvents.some((e) => e.event === "auth.login.failure")).toBe(true); + }); + + it("never logs raw session tokens or cookies", async () => { + const app = createApp(buildAuthEnabledEnv()); + + const cookie = buildAuthCookie({ sid: "redaction-test" }); + const tokenValue = cookie.split("=")[1]; + + await app.request("/auth/logout", { + method: "POST", + headers: { + Cookie: cookie, + Origin: "https://rack.example.com", + }, + }); + + const allOutput = writeSpy.mock.calls.map((c) => c[0] as string).join(""); + const authLines = allOutput + .split("\n") + .filter((line) => { + try { + const parsed = JSON.parse(line); + return parsed.event?.startsWith("auth."); + } catch { + return false; + } + }); + + for (const line of authLines) { + expect(line).not.toContain(tokenValue); + expect(line).not.toContain("rackula_auth_session="); + } + }); +}); diff --git a/api/src/auth-logger.ts b/api/src/auth-logger.ts new file mode 100644 index 00000000..b2d00c88 --- /dev/null +++ b/api/src/auth-logger.ts @@ -0,0 +1,216 @@ +/** + * Structured authentication event logger. + * + * Emits JSON lines to stdout for Docker/self-hosted log forwarding. + * All sensitive fields (tokens, passwords, session IDs) are redacted. + */ +import { createHmac } from "node:crypto"; + +export type AuthEventType = + | "auth.login.success" + | "auth.login.failure" + | "auth.logout" + | "auth.session.invalid" + | "auth.denied"; + +export interface AuthEvent { + timestamp: string; + event: AuthEventType; + subject?: string; + reason?: string; + method?: string; + path?: string; + ip?: string; +} + +const DEFAULT_AUTH_LOG_HASH_KEY = "rackula:auth-log:v1:default"; +export const MIN_AUTH_LOG_HASH_KEY_LENGTH = 16; +type AuthIdentifierType = "subject" | "ip"; +let authLogHashKey: string | undefined; +let hasWarnedDefaultHashKey = false; + +export function resetAuthLogHashConfigForTests(): void { + authLogHashKey = undefined; + hasWarnedDefaultHashKey = false; +} + +export function getAuthLogHashKeyForTests(): string | undefined { + return authLogHashKey; +} + +// Fields that must never appear in logs. +const REDACTED_FIELDS = new Set([ + "authorization", + "cookie", + "set-cookie", + "x-forwarded-for", +]); + +/** + * Configures the keyed hash input used to pseudonymize log identifiers. + */ +export function configureAuthLogHashKey(hashKey: string | undefined): void { + const normalized = hashKey?.trim(); + const normalizedLength = normalized?.length ?? 0; + if ( + normalizedLength > 0 && + normalizedLength < MIN_AUTH_LOG_HASH_KEY_LENGTH + ) { + // Primary validation lives in resolveApiSecurityConfig(security.ts); this is a + // defensive fallback for direct caller usage that bypasses config resolution. + console.warn( + `[auth-logger] Ignoring auth log hash key shorter than ${MIN_AUTH_LOG_HASH_KEY_LENGTH} characters. This fallback applies only when resolveApiSecurityConfig is not used.`, + ); + authLogHashKey = undefined; + } else { + authLogHashKey = normalizedLength > 0 ? normalized : undefined; + if (authLogHashKey) { + console.debug( + `[auth-logger] Auth log hash key configured (>=${MIN_AUTH_LOG_HASH_KEY_LENGTH} chars).`, + ); + } + } + hasWarnedDefaultHashKey = false; +} + +function getAuthLogHashKey(): string { + if (authLogHashKey) { + return authLogHashKey; + } + + if (!hasWarnedDefaultHashKey) { + console.warn( + "[auth-logger] No auth log hash key configured. Falling back to default auth log hash key; configure RACKULA_AUTH_LOG_HASH_KEY to avoid predictable cross-instance pseudonyms.", + ); + hasWarnedDefaultHashKey = true; + } + + return DEFAULT_AUTH_LOG_HASH_KEY; +} + +/** + * Pseudonymizes potentially identifying values using a keyed SHA-256 hash. + */ +export function pseudonymizeIdentifier( + value: string, + identifierType: AuthIdentifierType, +): string { + const normalized = normalizeIdentifier(value); + if (!normalized) { + throw new Error("Cannot pseudonymize an empty identifier value."); + } + + return pseudonymizeNormalizedIdentifier(normalized, identifierType); +} + +function normalizeIdentifier(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function pseudonymizeNormalizedIdentifier( + normalizedValue: string, + identifierType: AuthIdentifierType, +): string { + return createHmac("sha256", getAuthLogHashKey()) + .update(`${identifierType}:${normalizedValue}`) + .digest("hex"); +} + +function pseudonymizeOptionalIdentifier( + value: string | undefined, + identifierType: AuthIdentifierType, +): string | undefined { + const normalized = normalizeIdentifier(value); + if (!normalized) { + return undefined; + } + + return pseudonymizeNormalizedIdentifier(normalized, identifierType); +} + +/** + * Extracts minimal, safe request context for logging. + * + * Uses `x-real-ip` exclusively for client IP — this header is set by the trusted + * reverse proxy (nginx). `x-forwarded-for` is intentionally excluded because it + * is client-spoofable and is already listed in {@link REDACTED_FIELDS}. + */ +export function extractRequestContext( + request: Request, +): Pick { + const url = new URL(request.url); + return { + method: request.method, + path: url.pathname, + ip: request.headers.get("x-real-ip") ?? undefined, + }; +} + +/** + * Redacts a header map, replacing sensitive values with "[REDACTED]". + */ +export function redactHeaders( + headers: Record, +): Record { + const redacted: Record = {}; + for (const [key, value] of Object.entries(headers)) { + redacted[key] = REDACTED_FIELDS.has(key.toLowerCase()) + ? "[REDACTED]" + : value; + } + return redacted; +} + +/** + * Emits a structured auth event as a JSON line to stdout. + */ +export function emitAuthEvent(event: AuthEvent): void { + const sanitizedEvent: AuthEvent = { + ...event, + subject: pseudonymizeOptionalIdentifier(event.subject, "subject"), + ip: pseudonymizeOptionalIdentifier(event.ip, "ip"), + }; + const line = JSON.stringify(sanitizedEvent); + // Use process.stdout.write for atomic line output in Docker environments. + process.stdout.write(`${line}\n`); +} + +/** + * Convenience: build and emit an auth event with request context. + */ +export function logAuthEvent( + eventType: AuthEventType, + request: Request, + extra?: { subject?: string; reason?: string }, +): void { + const ctx = extractRequestContext(request); + emitAuthEvent({ + timestamp: new Date().toISOString(), + event: eventType, + ...ctx, + ...extra, + }); +} + +/** + * Fault-tolerant wrapper around {@link logAuthEvent}. + * + * Swallows and logs errors so that a logging failure (e.g. HMAC misconfiguration) + * never crashes the request handler that called it. + */ +export function safeLogAuthEvent( + eventType: AuthEventType, + request: Request, + extra?: { subject?: string; reason?: string }, +): void { + try { + logAuthEvent(eventType, request, extra); + } catch (err) { + console.error("[auth-logger] Failed to log auth event:", err); + } +} diff --git a/api/src/authorization.ts b/api/src/authorization.ts index 58001083..9a4d58d2 100644 --- a/api/src/authorization.ts +++ b/api/src/authorization.ts @@ -1,5 +1,6 @@ import type { MiddlewareHandler } from "hono"; import { STATE_CHANGING_METHODS, type AuthSessionClaims } from "./security"; +import { safeLogAuthEvent } from "./auth-logger"; // Role constants — single admin role for MVP. // Future roles (editor, viewer) can be added here without changing middleware shape. @@ -42,6 +43,11 @@ export function createRequireAdminMiddleware(): MiddlewareHandler { } if (!isAdmin(claims)) { + safeLogAuthEvent("auth.denied", c.req.raw, { + subject: claims.sub, + reason: `role "${claims.role ?? "none"}" is not admin`, + }); + return c.json( { error: "Forbidden", diff --git a/api/src/security.test.ts b/api/src/security.test.ts index 653d95d0..44cdac2d 100644 --- a/api/src/security.test.ts +++ b/api/src/security.test.ts @@ -199,6 +199,43 @@ describe("resolveApiSecurityConfig", () => { expect(config.csrfTrustedOrigins).toEqual(["https://rack.example.com"]); }); + it("derives auth log hash key from auth session secret by default", () => { + const first = resolveApiSecurityConfig(buildAuthEnabledEnv()); + const second = resolveApiSecurityConfig(buildAuthEnabledEnv()); + expect(first.authLogHashKey).toBe(second.authLogHashKey); + expect(first.authLogHashKey).toMatch(/^[a-f0-9]{64}$/); + expect(first.authLogHashKey).not.toBe(TEST_AUTH_SECRET); + }); + + it("accepts explicit auth log hash key override", () => { + const config = resolveApiSecurityConfig( + buildAuthEnabledEnv({ + RACKULA_AUTH_LOG_HASH_KEY: "rackula-auth-log-key-override", + }), + ); + + expect(config.authLogHashKey).toBe("rackula-auth-log-key-override"); + }); + + it("rejects short auth log hash key overrides", () => { + expect(() => + resolveApiSecurityConfig( + buildAuthEnabledEnv({ + RACKULA_AUTH_LOG_HASH_KEY: "too-short", + }), + ), + ).toThrow("RACKULA_AUTH_LOG_HASH_KEY must be at least 16 characters."); + }); + + it("generates ephemeral auth log hash keys without configured secrets", () => { + const first = resolveApiSecurityConfig(buildEnv()); + const second = resolveApiSecurityConfig(buildEnv()); + + expect(first.authLogHashKey).toMatch(/^[a-f0-9]{64}$/); + expect(second.authLogHashKey).toMatch(/^[a-f0-9]{64}$/); + expect(first.authLogHashKey).not.toBe(second.authLogHashKey); + }); + it("rejects production startup when CORS_ORIGIN is missing", () => { expect(() => resolveApiSecurityConfig(buildEnv({ NODE_ENV: "production" })), diff --git a/api/src/security.ts b/api/src/security.ts index d955b849..573d7a37 100644 --- a/api/src/security.ts +++ b/api/src/security.ts @@ -1,5 +1,12 @@ import type { MiddlewareHandler } from "hono"; -import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import { + createHash, + createHmac, + randomBytes, + randomUUID, + timingSafeEqual, +} from "node:crypto"; +import { safeLogAuthEvent, MIN_AUTH_LOG_HASH_KEY_LENGTH } from "./auth-logger"; export type AuthMode = "none" | "oidc" | "local"; export type AuthSessionSameSite = "Lax" | "Strict" | "None"; @@ -45,6 +52,7 @@ export interface ApiSecurityConfig { authMode: AuthMode; authEnabled: boolean; authSessionSecret?: string; + authLogHashKey: string; authSessionCookieName: string; authSessionCookieSecure: boolean; authSessionCookieSameSite: AuthSessionSameSite; @@ -81,6 +89,8 @@ const CORS_ORIGIN_EMPTY_ERROR = "CORS_ORIGIN is set but empty. Provide at least one origin."; const DEFAULT_AUTH_SESSION_MAX_AGE_SECONDS = 12 * 60 * 60; const DEFAULT_AUTH_SESSION_IDLE_TIMEOUT_SECONDS = 30 * 60; +const AUTH_LOG_HASH_CONTEXT = "rackula:auth-log:v1:"; +const GENERATED_AUTH_LOG_HASH_KEY_BYTES = 32; const MIN_AUTH_SESSION_TIMEOUT_SECONDS = 60; const MAX_AUTH_SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; const AUTH_SESSION_REFRESH_THRESHOLD_SECONDS = 60; @@ -109,6 +119,40 @@ function parseBoolean(value: string | undefined): boolean { return value?.toLowerCase() === "true"; } +function deriveAuthLogHashKey(authSessionSecret: string): string { + return createHmac("sha256", authSessionSecret) + .update(AUTH_LOG_HASH_CONTEXT) + .digest("hex"); +} + +function resolveAuthLogHashKey(options: { + authLogHashKeyRaw: string | undefined; + authSessionSecret: string | undefined; + isProduction: boolean; +}): string { + const normalizedAuthLogHashKey = options.authLogHashKeyRaw?.trim(); + if (normalizedAuthLogHashKey) { + if (normalizedAuthLogHashKey.length < MIN_AUTH_LOG_HASH_KEY_LENGTH) { + throw new Error( + `RACKULA_AUTH_LOG_HASH_KEY must be at least ${MIN_AUTH_LOG_HASH_KEY_LENGTH} characters.`, + ); + } + return normalizedAuthLogHashKey; + } + + if (options.authSessionSecret) { + return deriveAuthLogHashKey(options.authSessionSecret); + } + + if (options.isProduction) { + console.warn( + "⚠ Auth log hash key is not configured and no auth session secret is available. Generating an ephemeral per-process key. Set RACKULA_AUTH_LOG_HASH_KEY in production for stable pseudonymization.", + ); + } + + return randomBytes(GENERATED_AUTH_LOG_HASH_KEY_BYTES).toString("hex"); +} + function parseOptionalBoolean( name: string, value: string | undefined, @@ -281,7 +325,9 @@ function normalizeOrigin(input: string): string { return url.origin; } catch (error) { const reason = error instanceof Error ? error.message : String(error); - throw new Error(`Invalid CORS origin "${input}": ${reason}`); + throw new Error(`Invalid CORS origin "${input}": ${reason}`, { + cause: error, + }); } } @@ -923,6 +969,7 @@ export function resolveApiSecurityConfig( const authSessionSecretRaw = env.RACKULA_AUTH_SESSION_SECRET ?? env.AUTH_SESSION_SECRET; const authSessionSecret = authSessionSecretRaw?.trim() || undefined; + const authLogHashKeyRaw = env.RACKULA_AUTH_LOG_HASH_KEY ?? env.AUTH_LOG_HASH_KEY; if (authEnabled && !authSessionSecret) { throw new Error( @@ -940,6 +987,12 @@ export function resolveApiSecurityConfig( ); } + const authLogHashKey = resolveAuthLogHashKey({ + authLogHashKeyRaw, + authSessionSecret, + isProduction, + }); + const authSessionMaxAgeSeconds = parseBoundedPositiveInteger( "RACKULA_AUTH_SESSION_MAX_AGE_SECONDS", env.RACKULA_AUTH_SESSION_MAX_AGE_SECONDS, @@ -1000,6 +1053,7 @@ export function resolveApiSecurityConfig( authMode, authEnabled, authSessionSecret, + authLogHashKey, authSessionCookieName, authSessionCookieSecure, authSessionCookieSameSite, @@ -1062,6 +1116,10 @@ export function createAuthGateMiddleware( return; } + safeLogAuthEvent("auth.session.invalid", c.req.raw, { + reason: "missing or invalid session cookie", + }); + if (isApiRequestPath(pathname)) { return c.json( {