From 1024a382ee8843b3a661d8ce98e494fc4566d4ac Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:25:00 -0700 Subject: [PATCH] Enhancement: allow OIDC auto-login (#7096) --- docs/installation/index.md | 5 ++ src/__tests__/pages/auth/signin.test.jsx | 95 +++++++++++++++++++++++- src/components/toggles/signout.jsx | 6 +- src/components/toggles/signout.test.jsx | 3 +- src/middleware.js | 5 +- src/middleware.test.js | 12 +++ src/pages/auth/signin.jsx | 48 ++++++++++-- 7 files changed, 162 insertions(+), 12 deletions(-) diff --git a/docs/installation/index.md b/docs/installation/index.md index d318f7649..2f599077a 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -69,6 +69,11 @@ For OIDC login (overrides password login): - `HOMEPAGE_OIDC_CLIENT_ID` - `HOMEPAGE_OIDC_CLIENT_SECRET` - Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`) +- Optional: `HOMEPAGE_OIDC_AUTO_LOGIN=true` to skip the login page and send unauthenticated visitors straight to the provider + +!!! tip + + With auto-login enabled, visit `/auth/signin?autologin=0` to reach the login page without being redirected, e.g. in case something goes wrong! !!! warning diff --git a/src/__tests__/pages/auth/signin.test.jsx b/src/__tests__/pages/auth/signin.test.jsx index 317234f38..0755c81ef 100644 --- a/src/__tests__/pages/auth/signin.test.jsx +++ b/src/__tests__/pages/auth/signin.test.jsx @@ -1,11 +1,15 @@ // @vitest-environment jsdom import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { StrictMode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const { getSettingsMock, authOptionsMock } = vi.hoisted(() => ({ +const { getSettingsMock, authOptionsMock, signInMock, replaceMock, routerQuery } = vi.hoisted(() => ({ getSettingsMock: vi.fn(), authOptionsMock: vi.fn(), + signInMock: vi.fn(), + replaceMock: vi.fn(), + routerQuery: {}, })); vi.mock("utils/config/config", () => ({ @@ -20,13 +24,25 @@ vi.mock("pages/api/auth/[...nextauth]", () => ({ vi.mock("next/router", () => ({ useRouter: () => ({ - query: {}, + query: routerQuery, + replace: replaceMock, }), })); +vi.mock("next-auth/react", () => ({ signIn: signInMock })); + import SignInPage, { getServerSideProps } from "pages/auth/signin"; +const OIDC_PROVIDERS = { "homepage-oidc": { id: "homepage-oidc", name: "Homepage OIDC", type: "oauth" } }; +const SETTINGS = { theme: "dark", color: "slate", title: "Homepage" }; + describe("pages/auth/signin", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.keys(routerQuery).forEach((key) => delete routerQuery[key]); + window.sessionStorage.clear(); + }); + it("renders an error state when no providers are configured", async () => { render( { expect(screen.getByRole("button", { name: /login via oidc/i })).toBeInTheDocument(); }); + it("redirects to the provider when auto-login is enabled", () => { + routerQuery.callbackUrl = "/some/page"; + + render(); + + expect(signInMock).toHaveBeenCalledWith("homepage-oidc", { callbackUrl: "/some/page" }); + expect(screen.getByText(/redirecting to homepage oidc/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /login via/i })).not.toBeInTheDocument(); + }); + + it("does not auto-login when the provider returned an error", () => { + routerQuery.error = "OAuthCallback"; + + render(); + + expect(signInMock).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /login via homepage oidc/i })).toBeInTheDocument(); + }); + + it("does not auto-login when it is explicitly disabled in the url", () => { + routerQuery.autologin = "0"; + + render(); + + expect(signInMock).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /login via homepage oidc/i })).toBeInTheDocument(); + }); + + it("redirects once under strict mode, rather than tripping its own loop guard", () => { + render( + + + , + ); + + expect(signInMock).toHaveBeenCalledTimes(1); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("stops auto-login and hands back the page when the session never sticks", () => { + routerQuery.callbackUrl = "/some/page"; + + render(); + render(); + + expect(signInMock).toHaveBeenCalledTimes(1); + expect(replaceMock).toHaveBeenCalledWith("/auth/signin?autologin=0&callbackUrl=%2Fsome%2Fpage"); + }); + + it("does not auto-login the password provider", () => { + render( + , + ); + + expect(signInMock).not.toHaveBeenCalled(); + }); + it("getServerSideProps returns providers and only public sign-in settings", async () => { authOptionsMock.mockReturnValueOnce({ providers: [{ id: "foo", name: "Foo", type: "oauth" }] }); getSettingsMock.mockReturnValueOnce({ @@ -88,6 +165,7 @@ describe("pages/auth/signin", () => { expect(getSettingsMock).toHaveBeenCalled(); expect(res).toEqual({ props: { + autoLogin: false, providers: { foo: { id: "foo", name: "Foo", type: "oauth" } }, settings: { theme: "dark", @@ -102,6 +180,17 @@ describe("pages/auth/signin", () => { expect(res.props.settings).not.toHaveProperty("layout"); }); + it("getServerSideProps enables auto-login from the environment", async () => { + authOptionsMock.mockReturnValueOnce({ providers: [] }); + getSettingsMock.mockReturnValueOnce({ theme: "dark" }); + vi.stubEnv("HOMEPAGE_OIDC_AUTO_LOGIN", "true"); + + const res = await getServerSideProps({}); + + expect(res.props.autoLogin).toBe(true); + vi.unstubAllEnvs(); + }); + it("getServerSideProps falls back to no providers when auth options fail to load", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); authOptionsMock.mockImplementationOnce(() => { diff --git a/src/components/toggles/signout.jsx b/src/components/toggles/signout.jsx index 2a0ac09fc..c51587659 100644 --- a/src/components/toggles/signout.jsx +++ b/src/components/toggles/signout.jsx @@ -12,7 +12,11 @@ export default function SignOut() { return (
- diff --git a/src/components/toggles/signout.test.jsx b/src/components/toggles/signout.test.jsx index 2635cea83..74a202bfc 100644 --- a/src/components/toggles/signout.test.jsx +++ b/src/components/toggles/signout.test.jsx @@ -32,6 +32,7 @@ describe("components/toggles/signout", () => { const { getByRole } = render(); fireEvent.click(getByRole("button")); - expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/" }); + // Not "/", which would bounce straight back into the provider when auto-login is on + expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/signin?autologin=0" }); }); }); diff --git a/src/middleware.js b/src/middleware.js index d97e49320..884d8893f 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -32,7 +32,7 @@ export async function middleware(req) { return NextResponse.json({ error: "Host validation failed. See logs for more details." }, { status: 400 }); } - const pathname = new URL(req.url).pathname; + const { pathname, search } = new URL(req.url); const isPublicAuthPath = pathname.startsWith("/api/healthcheck") || pathname === "/api/config/custom.css"; if (authEnabled && !isPublicAuthPath) { // The MCP API handler authorizes both bearer tokens and Homepage sessions. @@ -43,7 +43,8 @@ export async function middleware(req) { const token = await getToken({ req, secret: authSecret }); if (!token) { const signInUrl = new URL("/auth/signin", req.url); - signInUrl.searchParams.set("callbackUrl", "/"); + // Same-origin by construction, so this cannot be used as an open redirect + signInUrl.searchParams.set("callbackUrl", `${pathname}${search}`); return withPrivateCache(NextResponse.redirect(signInUrl)); } } diff --git a/src/middleware.test.js b/src/middleware.test.js index 532161ce2..a8890e6b8 100644 --- a/src/middleware.test.js +++ b/src/middleware.test.js @@ -153,6 +153,18 @@ describe("middleware", () => { expect(String(res.url)).toContain("/auth/signin"); }); + it("preserves the requested path and query as the callback url", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + + getToken.mockResolvedValueOnce(null); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("localhost:3000", "http://localhost:3000/some/page?tab=2")); + + expect(new URL(res.url).searchParams.get("callbackUrl")).toBe("/some/page?tab=2"); + }); + it("allows requests when auth is enabled and a token is present", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_SECRET = "secret"; diff --git a/src/pages/auth/signin.jsx b/src/pages/auth/signin.jsx index 5f803bf53..10e9ece78 100644 --- a/src/pages/auth/signin.jsx +++ b/src/pages/auth/signin.jsx @@ -1,14 +1,16 @@ import classNames from "classnames"; import { signIn } from "next-auth/react"; import { useRouter } from "next/router"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { BiShieldQuarter } from "react-icons/bi"; import { getSettings } from "utils/config/config"; const PUBLIC_SIGN_IN_SETTINGS = ["theme", "color", "title", "background", "backgroundOpacity"]; +const AUTO_LOGIN_KEY = "homepage-autologin-attempt"; +const AUTO_LOGIN_RETRY_MS = 10000; -export default function SignIn({ providers, settings }) { +export default function SignIn({ providers, settings, autoLogin }) { const router = useRouter(); const [password, setPassword] = useState(""); const theme = settings?.theme || "dark"; @@ -20,6 +22,36 @@ export default function SignIn({ providers, settings }) { }, [router.query?.callbackUrl]); const error = router.query?.error; + const oidcProvider = useMemo( + () => Object.values(providers ?? {}).find((provider) => provider.type !== "credentials"), + [providers], + ); + // Try to avoid auto-login loop, e.g. if there was an error (or explicitly disabled via query param) + const autoLoginBlocked = Boolean(error) || router.query?.autologin === "0"; + const redirecting = Boolean(autoLogin && oidcProvider && !autoLoginBlocked); + + const attempted = useRef(false); + + useEffect(() => { + if (!redirecting || attempted.current) return; + attempted.current = true; + + let lastAttempt = 0; + try { + lastAttempt = Number(window.sessionStorage.getItem(AUTO_LOGIN_KEY)) || 0; + window.sessionStorage.setItem(AUTO_LOGIN_KEY, String(Date.now())); + } catch { + // sessionStorage throws when site data is blocked, fall through and redirect anyway + } + // Getting here quickly means the session never stuck, hand it back to the user + if (Date.now() - lastAttempt < AUTO_LOGIN_RETRY_MS) { + router.replace(`/auth/signin?autologin=0&callbackUrl=${encodeURIComponent(callbackUrl)}`); + return; + } + + signIn(oidcProvider.id, { callbackUrl }); + }, [redirecting, oidcProvider, callbackUrl, router]); + let backgroundImage = ""; let opacity = settings?.backgroundOpacity ?? 0; let backgroundBlur = false; @@ -143,7 +175,12 @@ export default function SignIn({ providers, settings }) {

Sign in

- {hasPasswordProvider && ( + {redirecting && ( +

+ Redirecting to {oidcProvider?.name}… +

+ )} + {!redirecting && hasPasswordProvider && (
{ @@ -173,7 +210,8 @@ export default function SignIn({ providers, settings }) {
)} - {!hasPasswordProvider && + {!redirecting && + !hasPasswordProvider && Object.values(providers).map((provider) => (