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";
|
import { assertSecureRuntimeEnv, getRuntimeEnvViolations } from "./runtime-env";
|
||||||
|
|
||||||
describe("runtime env validation", () => {
|
describe("runtime env validation", () => {
|
||||||
it("allows non-production environments without auth runtime settings", () => {
|
it("allows non-production environments once an auth secret is present", () => {
|
||||||
expect(getRuntimeEnvViolations({ NODE_ENV: "development" })).toEqual([]);
|
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", () => {
|
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_SECRET: "dev-secret-change-in-production",
|
||||||
NEXTAUTH_URL: "not-a-url",
|
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";
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRuntimeEnvViolations(env: RuntimeEnv = process.env): string[] {
|
function isHttpsUrl(value: string | null): boolean {
|
||||||
if (!isProductionLike(env)) {
|
if (!value) return false;
|
||||||
return [];
|
try {
|
||||||
|
return new URL(value).protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeEnvViolations(env: RuntimeEnv = process.env): string[] {
|
||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
const authSecret = readEnvValue(env, "AUTH_SECRET", "NEXTAUTH_SECRET");
|
const authSecret = readEnvValue(env, "AUTH_SECRET", "NEXTAUTH_SECRET");
|
||||||
const authUrl = readEnvValue(env, "AUTH_URL", "NEXTAUTH_URL");
|
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) {
|
if (!authSecret) {
|
||||||
violations.push("AUTH_SECRET or NEXTAUTH_SECRET must be set in production.");
|
violations.push(
|
||||||
} else if (DISALLOWED_PRODUCTION_SECRETS.has(authSecret)) {
|
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(
|
violations.push(
|
||||||
"AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production.",
|
"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) {
|
if (authSecret.length < MIN_AUTH_SECRET_LENGTH) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`AUTH_SECRET or NEXTAUTH_SECRET must be at least ${MIN_AUTH_SECRET_LENGTH} characters in production.`,
|
`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;
|
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