diff --git a/.env.example b/.env.example index 2c82012..d7b0d49 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,7 @@ REDIS_URL=redis://localhost:6380 # Auth.js NEXTAUTH_URL=http://localhost:3100 +# Local development only. Production must provide a long random secret outside the repository. NEXTAUTH_SECRET=dev-secret-change-in-production # App diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 02141a2..08fc467 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -6,6 +6,9 @@ import NextAuth, { type NextAuthConfig } from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { verify } from "@node-rs/argon2"; import { z } from "zod"; +import { assertSecureRuntimeEnv } from "./runtime-env"; + +assertSecureRuntimeEnv(); const LoginSchema = z.object({ email: z.string().email(), diff --git a/apps/web/src/server/runtime-env.test.ts b/apps/web/src/server/runtime-env.test.ts new file mode 100644 index 0000000..8c258ef --- /dev/null +++ b/apps/web/src/server/runtime-env.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { assertSecureRuntimeEnv, getRuntimeEnvViolations } from "./runtime-env"; + +describe("runtime env validation", () => { + it("allows non-production environments without auth runtime settings", () => { + expect(getRuntimeEnvViolations({ NODE_ENV: "development" })).toEqual([]); + }); + + it("accepts a valid production auth secret and https url", () => { + expect( + getRuntimeEnvViolations({ + NODE_ENV: "production", + NEXTAUTH_SECRET: "super-long-random-secret", + NEXTAUTH_URL: "https://capakraken.example.com", + }), + ).toEqual([]); + }); + + it("rejects a missing production auth secret", () => { + expect( + getRuntimeEnvViolations({ + NODE_ENV: "production", + NEXTAUTH_URL: "https://capakraken.example.com", + }), + ).toContain("AUTH_SECRET or NEXTAUTH_SECRET must be set in production."); + }); + + it("rejects the development placeholder auth secret in production", () => { + expect( + getRuntimeEnvViolations({ + NODE_ENV: "production", + NEXTAUTH_SECRET: "dev-secret-change-in-production", + NEXTAUTH_URL: "https://capakraken.example.com", + }), + ).toContain("AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production."); + }); + + it("rejects non-https auth urls in production", () => { + expect( + getRuntimeEnvViolations({ + NODE_ENV: "production", + NEXTAUTH_SECRET: "super-long-random-secret", + NEXTAUTH_URL: "http://capakraken.example.com", + }), + ).toContain("AUTH_URL or NEXTAUTH_URL must use https in production."); + }); + + it("throws with a combined startup error when production env is invalid", () => { + expect(() => + assertSecureRuntimeEnv({ + NODE_ENV: "production", + NEXTAUTH_SECRET: "dev-secret-change-in-production", + NEXTAUTH_URL: "not-a-url", + }), + ).toThrow(/Invalid production runtime configuration/); + }); +}); diff --git a/apps/web/src/server/runtime-env.ts b/apps/web/src/server/runtime-env.ts new file mode 100644 index 0000000..2d44a74 --- /dev/null +++ b/apps/web/src/server/runtime-env.ts @@ -0,0 +1,68 @@ +const DISALLOWED_PRODUCTION_SECRETS = new Set([ + "dev-secret-change-in-production", + "changeme", + "change-me", + "default", + "secret", +]); + +type RuntimeEnv = Partial>; + +function readEnvValue(env: RuntimeEnv, ...names: string[]): string | null { + for (const name of names) { + const value = env[name]?.trim(); + if (value) { + return value; + } + } + + return null; +} + +function isProductionLike(env: RuntimeEnv): boolean { + return (env.NODE_ENV ?? "").trim() === "production"; +} + +function isLocalhost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +export function getRuntimeEnvViolations(env: RuntimeEnv = process.env): string[] { + if (!isProductionLike(env)) { + return []; + } + + const violations: string[] = []; + const authSecret = readEnvValue(env, "AUTH_SECRET", "NEXTAUTH_SECRET"); + const authUrl = readEnvValue(env, "AUTH_URL", "NEXTAUTH_URL"); + + if (!authSecret) { + violations.push("AUTH_SECRET or NEXTAUTH_SECRET must be set in production."); + } else if (DISALLOWED_PRODUCTION_SECRETS.has(authSecret)) { + violations.push("AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production."); + } + + if (!authUrl) { + violations.push("AUTH_URL or NEXTAUTH_URL must be set in production."); + } else { + try { + const parsed = new URL(authUrl); + if (parsed.protocol !== "https:" && !isLocalhost(parsed.hostname)) { + violations.push("AUTH_URL or NEXTAUTH_URL must use https in production."); + } + } catch { + violations.push("AUTH_URL or NEXTAUTH_URL must be a valid URL in production."); + } + } + + return violations; +} + +export function assertSecureRuntimeEnv(env: RuntimeEnv = process.env): void { + const violations = getRuntimeEnvViolations(env); + if (violations.length === 0) { + return; + } + + throw new Error(`Invalid production runtime configuration: ${violations.join(" ")}`); +} diff --git a/docker-compose.yml b/docker-compose.yml index 7947e02..f54353a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,10 +52,10 @@ services: ports: - "3100:3100" environment: - DATABASE_URL: postgresql://capakraken:capakraken_dev@postgres:5432/capakraken - REDIS_URL: redis://redis:6379 - NEXTAUTH_URL: http://localhost:3100 - NEXTAUTH_SECRET: dev-secret-change-in-production + DATABASE_URL: ${DATABASE_URL:-postgresql://capakraken:capakraken_dev@postgres:5432/capakraken} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3100} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?set NEXTAUTH_SECRET} depends_on: postgres: condition: service_healthy @@ -64,7 +64,7 @@ services: volumes: - .:/app - /app/node_modules - - /app/.next + - /app/apps/web/.next profiles: - full diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md index 613f7ab..180ae3b 100644 --- a/docs/architecture-hardening-backlog.md +++ b/docs/architecture-hardening-backlog.md @@ -21,6 +21,7 @@ - `resource` is now onboarded as the second real comment entity, reusing the same ownership and staff-visibility rules as the resource detail route - comment mention autocomplete now uses a dedicated entity-scoped API route instead of inheriting the narrower `user.listAssignable` audience - runtime secret handling is now environment-first end to end: admin updates no longer persist new operational secrets, runtime status is surfaced explicitly, and legacy database secret copies can be cleared through a dedicated cleanup path +- production auth runtime config now fails fast when `AUTH_SECRET`/`NEXTAUTH_SECRET` is missing or left on a known development placeholder, and local compose no longer hardcodes that secret - `apps/web` system settings UI is now decomposed into section components with shared secret/runtime helpers, bringing all files in that slice back under the file-size guardrail - the first API-side `assistant-tools` extraction is in place: settings, system-role config, webhooks, audit log access, and shoring ratio now live in a dedicated domain module with shared assistant-tool types - the advanced timeline assistant toolset now lives in its own domain module, keeping the high-risk read/mutation pairings out of the monolithic router without changing the assistant contract @@ -61,9 +62,8 @@ That extraction work is now effectively complete for the current assistant-tool The small hardening slices are effectively exhausted. The remaining work is now structural rather than another quick batch: -1. secrets and runtime configuration policy -2. oversized router decomposition -3. performance hotspot reduction +1. oversized router decomposition +2. performance hotspot reduction ## Working Rule diff --git a/docs/security-architecture.md b/docs/security-architecture.md index ef5f006..c4748ae 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -67,6 +67,7 @@ publicProcedure - Admin settings reads expose only presence flags (`hasApiKey`, `hasSmtpPassword`, `hasGeminiApiKey`) instead of returning secret values to the browser, and those flags also reflect environment-backed runtime overrides - The admin settings mutation no longer persists new secret values into `SystemSettings`; secret inputs must be provisioned through environment or a deployment-time secret manager, and legacy database copies can be cleared explicitly - The admin UI now exposes runtime secret source/status plus an explicit "clear legacy DB secrets" cleanup path so operators can complete the migration without direct database writes +- Production startup now validates Auth.js runtime configuration and refuses to boot if `AUTH_SECRET`/`NEXTAUTH_SECRET` is missing, left on a known development placeholder, or paired with a non-HTTPS public auth URL ### Anonymization diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index cc01075..3721c19 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -5,6 +5,49 @@ import process from "node:process"; const rootDir = process.cwd(); const rules = [ + { + file: "apps/web/src/server/auth.ts", + required: [ + { + pattern: /\bassertSecureRuntimeEnv\s*\(/, + message: "Auth startup must validate production runtime env before serving requests", + }, + ], + forbidden: [], + }, + { + file: "apps/web/src/server/runtime-env.ts", + required: [ + { + pattern: /\bDISALLOWED_PRODUCTION_SECRETS\b/, + message: "runtime env validation must keep a denylist for known development secrets", + }, + { + pattern: /\bAUTH_SECRET\b/, + message: "runtime env validation must check the Auth.js secret environment variables", + }, + { + pattern: /\bNEXTAUTH_URL\b/, + message: "runtime env validation must check the public auth url environment variables", + }, + ], + forbidden: [], + }, + { + file: "docker-compose.yml", + required: [ + { + pattern: /NEXTAUTH_SECRET:\s+\$\{NEXTAUTH_SECRET:\?set NEXTAUTH_SECRET\}/, + message: "local compose must source NEXTAUTH_SECRET from environment instead of hardcoding it", + }, + ], + forbidden: [ + { + pattern: /NEXTAUTH_SECRET:\s+dev-secret-change-in-production/, + message: "local compose must not hardcode the development Auth.js secret", + }, + ], + }, { file: "packages/api/src/sse/event-bus.ts", required: [],