diff --git a/apps/web/src/server/runtime-env.test.ts b/apps/web/src/server/runtime-env.test.ts index a629ce8..01df164 100644 --- a/apps/web/src/server/runtime-env.test.ts +++ b/apps/web/src/server/runtime-env.test.ts @@ -2,8 +2,54 @@ 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("allows non-production environments once an auth secret is present", () => { + expect( + getRuntimeEnvViolations({ NODE_ENV: "development", AUTH_SECRET: "any-dev-secret" }), + ).toEqual([]); + }); + + it("does not apply production secret strength rules outside production", () => { + // Short, low-entropy and placeholder secrets stay acceptable in dev — only + // their total absence is fatal there. + expect( + getRuntimeEnvViolations({ + NODE_ENV: "development", + AUTH_SECRET: "dev-secret-change-in-production", + }), + ).toEqual([]); + }); + + it("rejects a missing auth secret in development too", () => { + // Regression guard: a dev server with no secret starts fine but answers + // every /api/auth/* route with an opaque 500, so login bounces back to the + // form with no error. It must fail at startup instead. + expect(getRuntimeEnvViolations({ NODE_ENV: "development" })).toContain( + "AUTH_SECRET or NEXTAUTH_SECRET must be set.", + ); + }); + + it("rejects E2E_TEST_MODE on an https deployment outside production", () => { + expect( + getRuntimeEnvViolations({ + NODE_ENV: "development", + AUTH_SECRET: "any-dev-secret", + NEXTAUTH_URL: "https://nexus.example.com", + E2E_TEST_MODE: "true", + }), + ).toContain( + "E2E_TEST_MODE must not be 'true' on an https deployment — it disables login rate limiting and session controls.", + ); + }); + + it("allows E2E_TEST_MODE against a local http deployment", () => { + expect( + getRuntimeEnvViolations({ + NODE_ENV: "development", + AUTH_SECRET: "any-dev-secret", + NEXTAUTH_URL: "http://localhost:3100", + E2E_TEST_MODE: "true", + }), + ).toEqual([]); }); it("accepts a valid production auth secret and https url", () => { @@ -76,6 +122,6 @@ describe("runtime env validation", () => { NEXTAUTH_SECRET: "dev-secret-change-in-production", NEXTAUTH_URL: "not-a-url", }), - ).toThrow(/Invalid production runtime configuration/); + ).toThrow(/Invalid runtime configuration/); }); }); diff --git a/apps/web/src/server/runtime-env.ts b/apps/web/src/server/runtime-env.ts index ad03bc6..957eaa2 100644 --- a/apps/web/src/server/runtime-env.ts +++ b/apps/web/src/server/runtime-env.ts @@ -58,22 +58,55 @@ 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 []; +function isHttpsUrl(value: string | null): boolean { + if (!value) return false; + try { + return new URL(value).protocol === "https:"; + } catch { + return false; } +} +export function getRuntimeEnvViolations(env: RuntimeEnv = process.env): string[] { const violations: string[] = []; const authSecret = readEnvValue(env, "AUTH_SECRET", "NEXTAUTH_SECRET"); const authUrl = readEnvValue(env, "AUTH_URL", "NEXTAUTH_URL"); + const production = isProductionLike(env); + // Checked in EVERY environment, not just production: without a secret + // Auth.js cannot sign session JWTs, so it answers *every* /api/auth/* route + // with an opaque 500 "problem with the server configuration" and the login + // form silently bounces the user back to itself with no error shown. + // A dev server started outside the monorepo root (which never loads the root + // .env) hits this, and the symptom points nowhere near the cause. Failing at + // startup turns a day of debugging into one readable line. if (!authSecret) { - violations.push("AUTH_SECRET or NEXTAUTH_SECRET must be set in production."); - } else if (DISALLOWED_PRODUCTION_SECRETS.has(authSecret)) { + violations.push( + production + ? "AUTH_SECRET or NEXTAUTH_SECRET must be set in production." + : "AUTH_SECRET or NEXTAUTH_SECRET must be set.", + ); + } + + // An https deployment URL means the instance is reachable off this machine, + // whatever NODE_ENV claims. E2E_TEST_MODE disables login rate limiting and + // the concurrent-session registry, so it must never be live there. The + // production case is covered by getDevBypassViolations() further down. + if (!production && env["E2E_TEST_MODE"] === "true" && isHttpsUrl(authUrl)) { + violations.push( + "E2E_TEST_MODE must not be 'true' on an https deployment — it disables login rate limiting and session controls.", + ); + } + + if (!production) { + return violations; + } + + if (authSecret && DISALLOWED_PRODUCTION_SECRETS.has(authSecret)) { violations.push( "AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production.", ); - } else { + } else if (authSecret) { if (authSecret.length < MIN_AUTH_SECRET_LENGTH) { violations.push( `AUTH_SECRET or NEXTAUTH_SECRET must be at least ${MIN_AUTH_SECRET_LENGTH} characters in production.`, @@ -110,5 +143,5 @@ export function assertSecureRuntimeEnv(env: RuntimeEnv = process.env): void { return; } - throw new Error(`Invalid production runtime configuration: ${violations.join(" ")}`); + throw new Error(`Invalid runtime configuration: ${violations.join(" ")}`); }