fix(auth): fail fast when the auth secret is missing, in any environment
A dev server started from apps/web never loads the monorepo root .env, so it
came up with no AUTH_SECRET/NEXTAUTH_SECRET at all. getRuntimeEnvViolations()
returned early for every non-production NODE_ENV, so nothing complained —
Auth.js then answered *every* /api/auth/* route with an opaque 500 ("problem
with the server configuration"). The login form swallowed that silently and
bounced the user back to itself with no error, pointing nowhere near the cause.
Two checks now run regardless of NODE_ENV:
- An auth secret must be present. Its absence is fatal everywhere, because
without it Auth.js cannot sign session JWTs and nothing about auth works.
The production-only strength rules (length, entropy, known placeholders)
are unchanged — a weak secret still only fails production.
- E2E_TEST_MODE must not be "true" when the deployment URL is https. An https
URL means the instance is reachable off the machine whatever NODE_ENV says,
and that flag disables login rate limiting and the concurrent-session
registry. The production case stays with getDevBypassViolations().
assertSecureRuntimeEnv() can now fire outside production, so its message drops
the inaccurate "production".
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(" ")}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user