mirror of
https://github.com/gethomepage/homepage.git
synced 2026-08-30 17:05:52 -07:00
Tweak: read auth providers in-process on the sign-in page, set env earlier (#7023)
This commit is contained in:
@@ -3,21 +3,27 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { getSettingsMock } = vi.hoisted(() => ({
|
const { getSettingsMock, authOptionsMock } = vi.hoisted(() => ({
|
||||||
getSettingsMock: vi.fn(),
|
getSettingsMock: vi.fn(),
|
||||||
|
authOptionsMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => ({
|
vi.mock("utils/config/config", () => ({
|
||||||
getSettings: getSettingsMock,
|
getSettings: getSettingsMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("pages/api/auth/[...nextauth]", () => ({
|
||||||
|
get authOptions() {
|
||||||
|
return authOptionsMock();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("next/router", () => ({
|
vi.mock("next/router", () => ({
|
||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
query: {},
|
query: {},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { getProviders } from "next-auth/react";
|
|
||||||
import SignInPage, { getServerSideProps } from "pages/auth/signin";
|
import SignInPage, { getServerSideProps } from "pages/auth/signin";
|
||||||
|
|
||||||
describe("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(() => {
|
await waitFor(() => {
|
||||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
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 () => {
|
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({
|
getSettingsMock.mockReturnValueOnce({
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
color: "slate",
|
color: "slate",
|
||||||
@@ -79,11 +85,10 @@ describe("pages/auth/signin", () => {
|
|||||||
|
|
||||||
const res = await getServerSideProps({});
|
const res = await getServerSideProps({});
|
||||||
|
|
||||||
expect(getProviders).toHaveBeenCalled();
|
|
||||||
expect(getSettingsMock).toHaveBeenCalled();
|
expect(getSettingsMock).toHaveBeenCalled();
|
||||||
expect(res).toEqual({
|
expect(res).toEqual({
|
||||||
props: {
|
props: {
|
||||||
providers: { foo: { id: "foo", name: "Foo" } },
|
providers: { foo: { id: "foo", name: "Foo", type: "oauth" } },
|
||||||
settings: {
|
settings: {
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
color: "slate",
|
color: "slate",
|
||||||
@@ -96,4 +101,18 @@ describe("pages/auth/signin", () => {
|
|||||||
expect(res.props.settings).not.toHaveProperty("providers");
|
expect(res.props.settings).not.toHaveProperty("providers");
|
||||||
expect(res.props.settings).not.toHaveProperty("layout");
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { applyNextAuthEnv } from "utils/env";
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||||
|
|
||||||
|
applyNextAuthEnv();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
|
|||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
|
|
||||||
import { isAuthEnabled } from "utils/env";
|
import { applyNextAuthEnv, isAuthEnabled } from "utils/env";
|
||||||
import createLogger from "utils/logger";
|
import createLogger from "utils/logger";
|
||||||
|
|
||||||
const MIN_AUTH_SECRET_LENGTH = 32;
|
const MIN_AUTH_SECRET_LENGTH = 32;
|
||||||
@@ -12,20 +12,13 @@ const authEnabled = isAuthEnabled();
|
|||||||
const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
|
const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
|
||||||
const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
|
const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
|
||||||
const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET;
|
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 homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD;
|
||||||
const homepageAuthPasswordDigest = homepageAuthPassword
|
const homepageAuthPasswordDigest = homepageAuthPassword
|
||||||
? createHash("sha256").update(homepageAuthPassword, "utf8").digest()
|
? createHash("sha256").update(homepageAuthPassword, "utf8").digest()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Map HOMEPAGE_* envs to what NextAuth expects
|
// Also done in instrumentation.js
|
||||||
if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) {
|
applyNextAuthEnv();
|
||||||
process.env.NEXTAUTH_SECRET = homepageAuthSecret;
|
|
||||||
}
|
|
||||||
if (!process.env.NEXTAUTH_URL && homepageExternalUrl) {
|
|
||||||
process.env.NEXTAUTH_URL = homepageExternalUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
|
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
|
||||||
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
|
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { getProviders, signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { BiShieldQuarter } from "react-icons/bi";
|
import { BiShieldQuarter } from "react-icons/bi";
|
||||||
@@ -94,10 +94,8 @@ export default function SignIn({ providers, settings }) {
|
|||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-theme-500/15 text-theme-600 dark:text-theme-300">
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-theme-500/15 text-theme-600 dark:text-theme-300">
|
||||||
<BiShieldQuarter className="h-6 w-6" />
|
<BiShieldQuarter className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">
|
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">Authentication error</h1>
|
||||||
Authentication not configured
|
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">Auth is disabled or misconfigured.</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">OIDC is disabled or misconfigured.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
@@ -202,7 +200,16 @@ export default function SignIn({ providers, settings }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
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 homepageSettings = getSettings();
|
||||||
const settings = Object.fromEntries(
|
const settings = Object.fromEntries(
|
||||||
PUBLIC_SIGN_IN_SETTINGS.filter((key) => Object.prototype.hasOwnProperty.call(homepageSettings, key)).map((key) => [
|
PUBLIC_SIGN_IN_SETTINGS.filter((key) => Object.prototype.hasOwnProperty.call(homepageSettings, key)).map((key) => [
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
export function isAuthEnabled() {
|
export function isAuthEnabled() {
|
||||||
return process.env.HOMEPAGE_AUTH_ENABLED === "true";
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ afterEach(() => {
|
|||||||
// Avoid NextAuth client-side fetches during unit tests.
|
// Avoid NextAuth client-side fetches during unit tests.
|
||||||
vi.mock("next-auth/react", () => ({
|
vi.mock("next-auth/react", () => ({
|
||||||
SessionProvider: ({ children }) => children ?? null,
|
SessionProvider: ({ children }) => children ?? null,
|
||||||
getProviders: vi.fn(async () => ({})),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// implement a couple of common formatters mocked in next-i18next
|
// implement a couple of common formatters mocked in next-i18next
|
||||||
|
|||||||
Reference in New Issue
Block a user