From ae8e72a7014b65b8925aad86e132e7afaed838f4 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:23:51 -0700 Subject: [PATCH] Enhancement: allow OIDC auto-login --- docs/installation/index.md | 5 ++ src/__tests__/pages/auth/signin.test.jsx | 88 +++++++++++++++++++++++- 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 | 41 +++++++++-- 7 files changed, 149 insertions(+), 11 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..7d016e8a0 100644 --- a/src/__tests__/pages/auth/signin.test.jsx +++ b/src/__tests__/pages/auth/signin.test.jsx @@ -1,11 +1,13 @@ // @vitest-environment jsdom import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const { getSettingsMock, authOptionsMock } = vi.hoisted(() => ({ +const { getSettingsMock, authOptionsMock, signInMock, routerQuery } = vi.hoisted(() => ({ getSettingsMock: vi.fn(), authOptionsMock: vi.fn(), + signInMock: vi.fn(), + routerQuery: {}, })); vi.mock("utils/config/config", () => ({ @@ -20,13 +22,23 @@ vi.mock("pages/api/auth/[...nextauth]", () => ({ vi.mock("next/router", () => ({ useRouter: () => ({ - query: {}, + query: routerQuery, }), })); +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]); + }); + 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("renders the button when the server disabled auto-login", () => { + render(); + + expect(signInMock).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /login via homepage oidc/i })).toBeInTheDocument(); + }); + + 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 +147,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 +162,28 @@ 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 disables auto-login while an attempt is pending", async () => { + authOptionsMock.mockReturnValueOnce({ providers: [] }); + getSettingsMock.mockReturnValueOnce({ theme: "dark" }); + vi.stubEnv("HOMEPAGE_OIDC_AUTO_LOGIN", "true"); + + const res = await getServerSideProps({ req: { cookies: { "homepage-autologin-attempt": "1" } } }); + + expect(res.props.autoLogin).toBe(false); + 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..d3125e38d 100644 --- a/src/pages/auth/signin.jsx +++ b/src/pages/auth/signin.jsx @@ -7,8 +7,10 @@ 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_COOKIE = "homepage-autologin-attempt"; +const AUTO_LOGIN_RETRY_SECONDS = 10; -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,24 @@ 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); + + useEffect(() => { + if (!redirecting) return; + + // getServerSideProps drops autoLogin while this is set, so a bounce loop falls back to the button + const secure = window.location.protocol === "https:" ? "; secure" : ""; + document.cookie = `${AUTO_LOGIN_COOKIE}=1; path=/auth; max-age=${AUTO_LOGIN_RETRY_SECONDS}; samesite=lax${secure}`; + + signIn(oidcProvider.id, { callbackUrl }); + }, [redirecting, oidcProvider, callbackUrl]); + let backgroundImage = ""; let opacity = settings?.backgroundOpacity ?? 0; let backgroundBlur = false; @@ -143,7 +163,12 @@ export default function SignIn({ providers, settings }) {

Sign in

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

+ Redirecting to {oidcProvider?.name}… +

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