From c4afced5facd4aa4227276a0e60a6983ca3a44e5 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:29:01 -0800 Subject: [PATCH] Basic password --- docs/installation/index.md | 13 ++- .../pages/api/auth/[...nextauth].test.js | 28 +++++- src/pages/api/auth/[...nextauth].js | 93 +++++++++++++------ src/pages/auth/signin.jsx | 67 +++++++++++-- 4 files changed, 159 insertions(+), 42 deletions(-) diff --git a/docs/installation/index.md b/docs/installation/index.md index 097721952..b4c6206a9 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -29,13 +29,22 @@ You have a few options for deploying homepage, depending on your needs. We offer ### Security & Authentication -Public deployments of Homepage should be secured via a reverse proxy, VPN, or similar. As of version 2.0, Homepage supports a simple OIDC login flow for built-in authorization. Enable it with the following environment variables: +Public deployments of Homepage should be secured via a reverse proxy, VPN, or similar. As of version 2.0, Homepage supports a simple authorization gate with a password or OIDC. When enabled, Homepage will use password login by default unless OIDC variables are provided. + +Required environment variables for authentication: - `HOMEPAGE_AUTH_ENABLED=true` +- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies) + +For password-only login: + +- `HOMEPAGE_AUTH_PASSWORD` (password-only login; required unless OIDC settings are provided) + +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_AUTH_SECRET` (random string for signing/encrypting cookies) - `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`) diff --git a/src/__tests__/pages/api/auth/[...nextauth].test.js b/src/__tests__/pages/api/auth/[...nextauth].test.js index d901013fd..6c1f9d6d9 100644 --- a/src/__tests__/pages/api/auth/[...nextauth].test.js +++ b/src/__tests__/pages/api/auth/[...nextauth].test.js @@ -38,14 +38,28 @@ describe("pages/api/auth/[...nextauth]", () => { expect(mod.default.options.secret).toBe("secret"); }); - it("throws when auth is enabled but required settings are missing", async () => { + it("throws when auth is enabled but no provider settings are present", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow( - /OIDC auth is enabled but required settings are missing/i, + /Password auth is enabled but required settings are missing/i, ); }); + 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"; + + const mod = await import("pages/api/auth/[...nextauth]"); + const [provider] = mod.default.options.providers; + + expect(provider.id).toBe("homepage-password"); + expect(provider.name).toBe("Password"); + expect(provider.type).toBe("credentials"); + expect(typeof provider.authorize).toBe("function"); + }); + it("builds an OIDC provider when enabled and maps profile fields", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example/"; @@ -97,4 +111,14 @@ describe("pages/api/auth/[...nextauth]", () => { image: null, }); }); + + 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"; + + 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 7d4325090..a08b56ca4 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -1,4 +1,6 @@ import NextAuth from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import { timingSafeEqual } from "node:crypto"; const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED); const issuer = process.env.HOMEPAGE_OIDC_ISSUER; @@ -6,6 +8,7 @@ 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; // Map HOMEPAGE_* envs to what NextAuth expects if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) { @@ -17,41 +20,75 @@ if (!process.env.NEXTAUTH_URL && homepageExternalUrl) { 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); -if ( - authEnabled && - (!issuer || !clientId || !clientSecret || !process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) -) { - throw new Error("OIDC auth is enabled but required settings are missing."); +if (authEnabled) { + if (hasOidcConfig) { + if (!process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) { + throw new Error("OIDC auth is enabled but required settings are missing."); + } + } else if (hasAnyOidcConfig) { + throw new Error("OIDC auth is enabled but required settings are missing."); + } else if (!homepageAuthPassword || !process.env.NEXTAUTH_SECRET) { + throw new Error("Password auth is enabled but required settings are missing."); + } } let providers = []; if (authEnabled) { - providers = [ - { - id: "homepage-oidc", - name: process.env.HOMEPAGE_OIDC_NAME || "Homepage OIDC", - type: "oauth", - idToken: true, - issuer: cleanedIssuer, - wellKnown: `${cleanedIssuer}/.well-known/openid-configuration`, - clientId, - clientSecret, - authorization: { - params: { - scope: defaultScope, + if (hasOidcConfig) { + providers = [ + { + id: "homepage-oidc", + name: process.env.HOMEPAGE_OIDC_NAME || "Homepage OIDC", + type: "oauth", + idToken: true, + issuer: cleanedIssuer, + wellKnown: `${cleanedIssuer}/.well-known/openid-configuration`, + clientId, + clientSecret, + authorization: { + params: { + scope: defaultScope, + }, + }, + profile(profile) { + return { + id: profile.sub ?? profile.id ?? profile.user_id ?? profile.uid ?? profile.email, + name: profile.name ?? profile.preferred_username ?? profile.nickname ?? profile.email, + email: profile.email ?? null, + image: profile.picture ?? null, + }; }, }, - profile(profile) { - return { - id: profile.sub ?? profile.id ?? profile.user_id ?? profile.uid ?? profile.email, - name: profile.name ?? profile.preferred_username ?? profile.nickname ?? profile.email, - email: profile.email ?? null, - image: profile.picture ?? null, - }; - }, - }, - ]; + ]; + } else { + providers = [ + CredentialsProvider({ + id: "homepage-password", + name: "Password", + credentials: { + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const provided = credentials?.password ?? ""; + const expected = homepageAuthPassword ?? ""; + if (!expected || provided.length !== expected.length) { + return null; + } + const isMatch = timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + if (!isMatch) { + return null; + } + return { + id: "homepage", + name: "Homepage", + }; + }, + }), + ]; + } } export default NextAuth({ diff --git a/src/pages/auth/signin.jsx b/src/pages/auth/signin.jsx index bb671463f..bca4fff87 100644 --- a/src/pages/auth/signin.jsx +++ b/src/pages/auth/signin.jsx @@ -1,14 +1,22 @@ import classNames from "classnames"; -import { getProviders } from "next-auth/react"; -import { useEffect } from "react"; +import { getProviders, signIn } from "next-auth/react"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState } from "react"; import { BiShieldQuarter } from "react-icons/bi"; import { getSettings } from "utils/config/config"; export default function SignIn({ providers, settings }) { + const router = useRouter(); + const [password, setPassword] = useState(""); const theme = settings?.theme || "dark"; const color = settings?.color || "slate"; const title = settings?.title || "Homepage"; + const callbackUrl = useMemo(() => { + const value = router.query?.callbackUrl; + return typeof value === "string" ? value : "/"; + }, [router.query?.callbackUrl]); + const error = router.query?.error; let backgroundImage = ""; let opacity = settings?.backgroundOpacity ?? 0; @@ -94,6 +102,8 @@ export default function SignIn({ providers, settings }) { ); } + const hasPasswordProvider = Boolean(providers?.["homepage-password"]); + return ( <> {backgroundImage && ( @@ -132,16 +142,53 @@ export default function SignIn({ providers, settings }) {

Sign in

- {Object.values(providers).map((provider) => ( - - ))} + + setPassword(event.target.value)} + autoComplete="current-password" + className="w-full rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-gray-900 shadow-sm outline-none ring-0 transition focus:border-theme-500 focus:ring-2 focus:ring-theme-500/30 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-100" + required + /> + + + )} + {!hasPasswordProvider && + Object.values(providers).map((provider) => ( + + ))}
+ {hasPasswordProvider && error && ( +

+ Invalid password. Please try again. +

+ )}