fix(auth): fail fast when the auth secret is missing, in any environment #67

Open
Hartmut wants to merge 4 commits from fix/auth-runtime-env-validation into main
2 changed files with 89 additions and 10 deletions
Showing only changes of commit f2910b0d0e - Show all commits
+49 -3
View File
@@ -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/);
});
});
+40 -7
View File
@@ -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(" ")}`);
}