diff --git a/docs/installation/index.md b/docs/installation/index.md index 9ed9b48ab..9dd363c69 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -47,6 +47,9 @@ Required environment variables for authentication: - `HOMEPAGE_AUTH_ENABLED=true` - `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies) +- `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. For password-only login: @@ -57,7 +60,6 @@ For OIDC login (overrides password login): - `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`) - `HOMEPAGE_OIDC_CLIENT_ID` - `HOMEPAGE_OIDC_CLIENT_SECRET` -- `HOMEPAGE_EXTERNAL_URL` (external URL to your Homepage instance; used for callbacks) - Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`) All app pages and `/api` routes except `/api/healthcheck` will require a signed-in session. Static assets remain public. diff --git a/src/__tests__/pages/api/auth/[...nextauth].test.js b/src/__tests__/pages/api/auth/[...nextauth].test.js index b6a6416af..a2a56f7f4 100644 --- a/src/__tests__/pages/api/auth/[...nextauth].test.js +++ b/src/__tests__/pages/api/auth/[...nextauth].test.js @@ -25,6 +25,7 @@ describe("pages/api/auth/[...nextauth]", () => { nextAuthMock.mockClear(); warnMock.mockClear(); process.env = { ...originalEnv }; + delete process.env.HOMEPAGE_EXTERNAL_URL; delete process.env.NEXTAUTH_SECRET; delete process.env.NEXTAUTH_URL; }); @@ -92,8 +93,33 @@ describe("pages/api/auth/[...nextauth]", () => { expect(mod.default.options.secret).toBe("secret"); }); + 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"; + + await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(/HOMEPAGE_EXTERNAL_URL.*is missing/i); + }); + + it.each([ + "homepage.example", + "ftp://homepage.example", + "https://user:password@homepage.example", + "https://homepage.example/?unexpected=true", + "https://homepage.example/#unexpected", + ])("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_EXTERNAL_URL = externalUrl; + + await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(/absolute HTTP\(S\) URL/i); + }); + 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_EXTERNAL_URL = "https://homepage.example"; await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow( /Password auth is enabled but required settings are missing/i, @@ -104,6 +130,7 @@ describe("pages/api/auth/[...nextauth]", () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "secret"; process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; const mod = await import("pages/api/auth/[...nextauth]"); const [provider] = mod.default.options.providers; @@ -112,6 +139,30 @@ describe("pages/api/auth/[...nextauth]", () => { expect(provider.name).toBe("Credentials"); expect(provider.type).toBe("credentials"); expect(typeof provider.authorize).toBe("function"); + expect(mod.default.options.useSecureCookies).toBe(true); + }); + + 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_EXTERNAL_URL = "http://192.168.1.20:3000"; + + const mod = await import("pages/api/auth/[...nextauth]"); + + expect(process.env.NEXTAUTH_URL).toBe("http://192.168.1.20:3000"); + expect(mod.default.options.useSecureCookies).toBe(false); + }); + + 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.NEXTAUTH_URL = "https://homepage.example"; + + const mod = await import("pages/api/auth/[...nextauth]"); + + expect(mod.default.options.useSecureCookies).toBe(true); }); it("builds an OIDC provider when enabled and maps profile fields", async () => { @@ -170,6 +221,7 @@ describe("pages/api/auth/[...nextauth]", () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example"; process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow( /OIDC auth is enabled but required settings are missing/i, diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index 42d687620..1b7c8e180 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -25,10 +25,33 @@ const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile"; const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer; const hasOidcConfig = Boolean(issuer && clientId && clientSecret); const hasAnyOidcConfig = Boolean(issuer || clientId || clientSecret); +let parsedAuthUrl; if (authEnabled) { + if (!process.env.NEXTAUTH_URL) { + throw new Error("Homepage auth is enabled but HOMEPAGE_EXTERNAL_URL (or NEXTAUTH_URL) is missing."); + } + + try { + parsedAuthUrl = new URL(process.env.NEXTAUTH_URL); + } catch { + throw new Error("HOMEPAGE_EXTERNAL_URL (or NEXTAUTH_URL) must be an absolute HTTP(S) URL."); + } + + if ( + !["http:", "https:"].includes(parsedAuthUrl.protocol) || + parsedAuthUrl.username || + parsedAuthUrl.password || + parsedAuthUrl.search || + parsedAuthUrl.hash + ) { + throw new Error( + "HOMEPAGE_EXTERNAL_URL (or NEXTAUTH_URL) must be an absolute HTTP(S) URL without credentials, query, or fragment.", + ); + } + if (hasOidcConfig) { - if (!process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) { + if (!process.env.NEXTAUTH_SECRET) { throw new Error("OIDC auth is enabled but required settings are missing."); } } else if (hasAnyOidcConfig) { @@ -99,6 +122,7 @@ export const authOptions = { strategy: "jwt", }, secret: process.env.NEXTAUTH_SECRET, + useSecureCookies: parsedAuthUrl?.protocol === "https:", pages: { signIn: "/auth/signin", }, diff --git a/src/pages/api/mcp/index.test.js b/src/pages/api/mcp/index.test.js index 266ea7fe3..961e0cc98 100644 --- a/src/pages/api/mcp/index.test.js +++ b/src/pages/api/mcp/index.test.js @@ -106,6 +106,7 @@ describe("pages/api/mcp", () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "password"; process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; getServerSession.mockResolvedValueOnce({ user: { name: "Homepage" } }); const handler = await loadHandler(); const res = mockResponse(); @@ -122,6 +123,7 @@ describe("pages/api/mcp", () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "password"; process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; getServerSession.mockResolvedValueOnce(null); const handler = await loadHandler(); const res = mockResponse(); @@ -137,6 +139,7 @@ describe("pages/api/mcp", () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "password"; process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; process.env.HOMEPAGE_MCP_TOKEN = "secret"; const handler = await loadHandler(); const res = mockResponse();