diff --git a/docs/installation/index.md b/docs/installation/index.md index 4a7491743..5b52ed3ff 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -46,7 +46,7 @@ Public deployments of Homepage should be secured via a reverse proxy, VPN, or si Required environment variables for authentication: - `HOMEPAGE_AUTH_ENABLED=true` -- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies) +- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies, at least 32 characters; generate one with `openssl rand -base64 32`) - `HOMEPAGE_EXTERNAL_URL` (the absolute URL used to access Homepage, including scheme and port when needed) Use an `https://` URL for public or TLS-terminated deployments so authentication cookies are marked `Secure`. Trusted HTTP-only LAN deployments may use an `http://` URL. diff --git a/src/__tests__/pages/api/auth/[...nextauth].test.js b/src/__tests__/pages/api/auth/[...nextauth].test.js index 7e1e7fdfd..fee9cb31f 100644 --- a/src/__tests__/pages/api/auth/[...nextauth].test.js +++ b/src/__tests__/pages/api/auth/[...nextauth].test.js @@ -122,7 +122,7 @@ describe("pages/api/auth/[...nextauth]", () => { it("throws when auth is enabled without an external URL", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "secret"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(/HOMEPAGE_EXTERNAL_URL.*is missing/i); }); @@ -136,7 +136,7 @@ describe("pages/api/auth/[...nextauth]", () => { ])("rejects invalid external URL %s", async (externalUrl) => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "secret"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.HOMEPAGE_EXTERNAL_URL = externalUrl; await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(/absolute HTTP\(S\) URL/i); @@ -144,7 +144,7 @@ describe("pages/api/auth/[...nextauth]", () => { it("throws when auth is enabled but no provider settings are present", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow( @@ -152,10 +152,38 @@ describe("pages/api/auth/[...nextauth]", () => { ); }); + it.each(["short", "a".repeat(31)])("throws when the auth secret is too weak (%j)", async (secret) => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_PASSWORD = "secret"; + process.env.HOMEPAGE_AUTH_SECRET = secret; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; + + await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(/at least 32 characters/i); + }); + + it("accepts an auth secret at exactly the minimum length", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_PASSWORD = "secret"; + process.env.HOMEPAGE_AUTH_SECRET = "a".repeat(32); + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; + + const mod = await import("pages/api/auth/[...nextauth]"); + + expect(mod.authOptions.providers).toHaveLength(1); + }); + + it("does not enforce the secret length when auth is disabled", async () => { + process.env.HOMEPAGE_AUTH_SECRET = "short"; + + const mod = await import("pages/api/auth/[...nextauth]"); + + expect(mod.authOptions.providers).toEqual([]); + }); + it("builds a password provider when auth is enabled without OIDC config", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "secret"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; const mod = await import("pages/api/auth/[...nextauth]"); @@ -177,7 +205,7 @@ describe("pages/api/auth/[...nextauth]", () => { it("logs failed password sign-in attempts without recording client-supplied data", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "secret"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; const mod = await import("pages/api/auth/[...nextauth]"); @@ -199,7 +227,7 @@ describe("pages/api/auth/[...nextauth]", () => { it("compares multibyte passwords without throwing on unequal byte lengths", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "é"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; const mod = await import("pages/api/auth/[...nextauth]"); @@ -215,7 +243,7 @@ describe("pages/api/auth/[...nextauth]", () => { it("supports trusted HTTP deployments without Secure cookies", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "secret"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.HOMEPAGE_EXTERNAL_URL = "http://192.168.1.20:3000"; const mod = await import("pages/api/auth/[...nextauth]"); @@ -227,7 +255,7 @@ describe("pages/api/auth/[...nextauth]", () => { it("accepts an explicitly configured NEXTAUTH_URL", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "secret"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.NEXTAUTH_URL = "https://homepage.example"; const mod = await import("pages/api/auth/[...nextauth]"); @@ -240,7 +268,7 @@ describe("pages/api/auth/[...nextauth]", () => { process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example/"; process.env.HOMEPAGE_OIDC_CLIENT_ID = "client-id"; process.env.HOMEPAGE_OIDC_CLIENT_SECRET = "client-secret"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; process.env.HOMEPAGE_OIDC_NAME = "My OIDC"; process.env.HOMEPAGE_OIDC_SCOPE = "openid email"; @@ -291,7 +319,7 @@ describe("pages/api/auth/[...nextauth]", () => { it("throws when only partial OIDC settings are provided", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow( diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index 5f35f0579..de9fe0445 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -6,6 +6,8 @@ import CredentialsProvider from "next-auth/providers/credentials"; import { isAuthEnabled } from "utils/env"; import createLogger from "utils/logger"; +const MIN_AUTH_SECRET_LENGTH = 32; + const authEnabled = isAuthEnabled(); const issuer = process.env.HOMEPAGE_OIDC_ISSUER; const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID; @@ -63,6 +65,12 @@ if (authEnabled) { } else if (!homepageAuthPassword || !process.env.NEXTAUTH_SECRET) { throw new Error("Password auth is enabled but required settings are missing."); } + + if (process.env.NEXTAUTH_SECRET.length < MIN_AUTH_SECRET_LENGTH) { + throw new Error( + `HOMEPAGE_AUTH_SECRET (or NEXTAUTH_SECRET) must be at least ${MIN_AUTH_SECRET_LENGTH} characters. Generate one with: openssl rand -base64 32`, + ); + } } // Give fail2ban / CrowdSec etc something to match on diff --git a/src/pages/api/mcp/index.test.js b/src/pages/api/mcp/index.test.js index 961e0cc98..378c9fd39 100644 --- a/src/pages/api/mcp/index.test.js +++ b/src/pages/api/mcp/index.test.js @@ -105,7 +105,7 @@ describe("pages/api/mcp", () => { process.env.HOMEPAGE_MCP_ENABLED = "true"; process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "password"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; getServerSession.mockResolvedValueOnce({ user: { name: "Homepage" } }); const handler = await loadHandler(); @@ -122,7 +122,7 @@ describe("pages/api/mcp", () => { process.env.HOMEPAGE_MCP_ENABLED = "true"; process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "password"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; getServerSession.mockResolvedValueOnce(null); const handler = await loadHandler(); @@ -138,7 +138,7 @@ describe("pages/api/mcp", () => { process.env.HOMEPAGE_MCP_ENABLED = "true"; process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "password"; - process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_AUTH_SECRET = "rk3Xk9wQ0mVJt7cZbN2yLpA8sHdF4gRuEwTiOaSvBnM="; process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; process.env.HOMEPAGE_MCP_TOKEN = "secret"; const handler = await loadHandler();