fix(auth): fail fast when the auth secret is missing, in any environment #67
@@ -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