diff --git a/src/__tests__/pages/auth/signin.test.jsx b/src/__tests__/pages/auth/signin.test.jsx index c9babf09d..261a327d2 100644 --- a/src/__tests__/pages/auth/signin.test.jsx +++ b/src/__tests__/pages/auth/signin.test.jsx @@ -3,21 +3,27 @@ import { render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -const { getSettingsMock } = vi.hoisted(() => ({ +const { getSettingsMock, authOptionsMock } = vi.hoisted(() => ({ getSettingsMock: vi.fn(), + authOptionsMock: vi.fn(), })); vi.mock("utils/config/config", () => ({ getSettings: getSettingsMock, })); +vi.mock("pages/api/auth/[...nextauth]", () => ({ + get authOptions() { + return authOptionsMock(); + }, +})); + vi.mock("next/router", () => ({ useRouter: () => ({ query: {}, }), })); -import { getProviders } from "next-auth/react"; import SignInPage, { getServerSideProps } from "pages/auth/signin"; describe("pages/auth/signin", () => { @@ -33,7 +39,7 @@ describe("pages/auth/signin", () => { />, ); - expect(screen.getByText("Authentication not configured")).toBeInTheDocument(); + expect(screen.getByText("Authentication error")).toBeInTheDocument(); await waitFor(() => { expect(document.documentElement.classList.contains("dark")).toBe(true); @@ -61,7 +67,7 @@ describe("pages/auth/signin", () => { }); it("getServerSideProps returns providers and only public sign-in settings", async () => { - getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } }); + authOptionsMock.mockReturnValueOnce({ providers: [{ id: "foo", name: "Foo", type: "oauth" }] }); getSettingsMock.mockReturnValueOnce({ theme: "dark", color: "slate", @@ -79,11 +85,10 @@ describe("pages/auth/signin", () => { const res = await getServerSideProps({}); - expect(getProviders).toHaveBeenCalled(); expect(getSettingsMock).toHaveBeenCalled(); expect(res).toEqual({ props: { - providers: { foo: { id: "foo", name: "Foo" } }, + providers: { foo: { id: "foo", name: "Foo", type: "oauth" } }, settings: { theme: "dark", color: "slate", @@ -96,4 +101,18 @@ describe("pages/auth/signin", () => { expect(res.props.settings).not.toHaveProperty("providers"); expect(res.props.settings).not.toHaveProperty("layout"); }); + + it("getServerSideProps falls back to no providers when auth options fail to load", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + authOptionsMock.mockImplementationOnce(() => { + throw new Error("Homepage auth is enabled but HOMEPAGE_EXTERNAL_URL (or NEXTAUTH_URL) is missing."); + }); + getSettingsMock.mockReturnValueOnce({ theme: "dark" }); + + const res = await getServerSideProps({}); + + expect(res.props.providers).toEqual({}); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); }); diff --git a/src/instrumentation.js b/src/instrumentation.js new file mode 100644 index 000000000..84b763f19 --- /dev/null +++ b/src/instrumentation.js @@ -0,0 +1,7 @@ +import { applyNextAuthEnv } from "utils/env"; + +export function register() { + if (process.env.NEXT_RUNTIME !== "nodejs") return; + + applyNextAuthEnv(); +} diff --git a/src/instrumentation.test.js b/src/instrumentation.test.js new file mode 100644 index 000000000..b3bc3d5da --- /dev/null +++ b/src/instrumentation.test.js @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("instrumentation", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + delete process.env.NEXTAUTH_SECRET; + delete process.env.NEXTAUTH_URL; + delete process.env.HOMEPAGE_AUTH_SECRET; + delete process.env.HOMEPAGE_EXTERNAL_URL; + process.env.NEXT_RUNTIME = "nodejs"; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("maps HOMEPAGE_* auth envs to their NextAuth equivalents", async () => { + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; + const { register } = await import("./instrumentation"); + + register(); + + expect(process.env.NEXTAUTH_SECRET).toBe("secret"); + expect(process.env.NEXTAUTH_URL).toBe("https://homepage.example"); + }); + + it("does not override explicitly configured NextAuth envs", async () => { + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; + process.env.NEXTAUTH_SECRET = "explicit-secret"; + process.env.NEXTAUTH_URL = "https://explicit.example"; + const { register } = await import("./instrumentation"); + + register(); + + expect(process.env.NEXTAUTH_SECRET).toBe("explicit-secret"); + expect(process.env.NEXTAUTH_URL).toBe("https://explicit.example"); + }); + + it("is a no-op outside the node runtime", async () => { + process.env.NEXT_RUNTIME = "edge"; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; + const { register } = await import("./instrumentation"); + + register(); + + expect(process.env.NEXTAUTH_URL).toBeUndefined(); + }); +}); diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index a133e922b..ce5a9f505 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -3,7 +3,7 @@ import { createHash, timingSafeEqual } from "node:crypto"; import NextAuth from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; -import { isAuthEnabled } from "utils/env"; +import { applyNextAuthEnv, isAuthEnabled } from "utils/env"; import createLogger from "utils/logger"; const MIN_AUTH_SECRET_LENGTH = 32; @@ -12,20 +12,13 @@ const authEnabled = isAuthEnabled(); const issuer = process.env.HOMEPAGE_OIDC_ISSUER; const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID; const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET; -const homepageAuthSecret = process.env.HOMEPAGE_AUTH_SECRET; -const homepageExternalUrl = process.env.HOMEPAGE_EXTERNAL_URL; const homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD; const homepageAuthPasswordDigest = homepageAuthPassword ? createHash("sha256").update(homepageAuthPassword, "utf8").digest() : null; -// Map HOMEPAGE_* envs to what NextAuth expects -if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) { - process.env.NEXTAUTH_SECRET = homepageAuthSecret; -} -if (!process.env.NEXTAUTH_URL && homepageExternalUrl) { - process.env.NEXTAUTH_URL = homepageExternalUrl; -} +// Also done in instrumentation.js +applyNextAuthEnv(); const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile"; const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer; diff --git a/src/pages/auth/signin.jsx b/src/pages/auth/signin.jsx index fad910091..5f803bf53 100644 --- a/src/pages/auth/signin.jsx +++ b/src/pages/auth/signin.jsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import { getProviders, signIn } from "next-auth/react"; +import { signIn } from "next-auth/react"; import { useRouter } from "next/router"; import { useEffect, useMemo, useState } from "react"; import { BiShieldQuarter } from "react-icons/bi"; @@ -94,10 +94,8 @@ export default function SignIn({ providers, settings }) {
-

- Authentication not configured -

-

OIDC is disabled or misconfigured.

+

Authentication error

+

Auth is disabled or misconfigured.

@@ -202,7 +200,16 @@ export default function SignIn({ providers, settings }) { } export async function getServerSideProps(context) { - const providers = await getProviders(); + // Avoid getProviders() fetch + let providers = {}; + try { + // Dynamic so a bad config throws in here rather than at page load + const { authOptions } = await import("pages/api/auth/[...nextauth]"); + providers = Object.fromEntries(authOptions.providers.map(({ id, name, type }) => [id, { id, name, type }])); + } catch (e) { + console.error("Unable to load auth providers: %s", e.message); + } + const homepageSettings = getSettings(); const settings = Object.fromEntries( PUBLIC_SIGN_IN_SETTINGS.filter((key) => Object.prototype.hasOwnProperty.call(homepageSettings, key)).map((key) => [ diff --git a/src/utils/env.js b/src/utils/env.js index da03d2e9f..14982d433 100644 --- a/src/utils/env.js +++ b/src/utils/env.js @@ -1,3 +1,13 @@ export function isAuthEnabled() { return process.env.HOMEPAGE_AUTH_ENABLED === "true"; } + +// Frozen at module load, so map them before anything imports it +export function applyNextAuthEnv() { + if (!process.env.NEXTAUTH_SECRET && process.env.HOMEPAGE_AUTH_SECRET) { + process.env.NEXTAUTH_SECRET = process.env.HOMEPAGE_AUTH_SECRET; + } + if (!process.env.NEXTAUTH_URL && process.env.HOMEPAGE_EXTERNAL_URL) { + process.env.NEXTAUTH_URL = process.env.HOMEPAGE_EXTERNAL_URL; + } +} diff --git a/vitest.setup.js b/vitest.setup.js index 1c5328ba3..6c03bd6d7 100644 --- a/vitest.setup.js +++ b/vitest.setup.js @@ -11,7 +11,6 @@ afterEach(() => { // Avoid NextAuth client-side fetches during unit tests. vi.mock("next-auth/react", () => ({ SessionProvider: ({ children }) => children ?? null, - getProviders: vi.fn(async () => ({})), })); // implement a couple of common formatters mocked in next-i18next