Enhancement: allow OIDC auto-login (#7096)

This commit is contained in:
shamoon
2026-09-05 08:25:00 -07:00
committed by GitHub
parent 438788240b
commit 1024a382ee
7 changed files with 162 additions and 12 deletions
+5
View File
@@ -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
+92 -3
View File
@@ -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(
<SignInPage
@@ -66,6 +82,67 @@ describe("pages/auth/signin", () => {
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(<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />);
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(<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />);
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(<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />);
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(
<StrictMode>
<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />
</StrictMode>,
);
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(<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />);
render(<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin />);
expect(signInMock).toHaveBeenCalledTimes(1);
expect(replaceMock).toHaveBeenCalledWith("/auth/signin?autologin=0&callbackUrl=%2Fsome%2Fpage");
});
it("does not auto-login the password provider", () => {
render(
<SignInPage
providers={{ credentials: { id: "credentials", name: "Password", type: "credentials" } }}
settings={SETTINGS}
autoLogin
/>,
);
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(() => {
+5 -1
View File
@@ -12,7 +12,11 @@ export default function SignOut() {
return (
<div id="signout" className="rounded-full flex align-middle self-center mr-3">
<button type="button" onClick={() => signOut({ callbackUrl: "/" })} className="outline-hidden">
<button
type="button"
onClick={() => signOut({ callbackUrl: "/auth/signin?autologin=0" })}
className="outline-hidden"
>
<MdLogout className="text-theme-800 dark:text-theme-200 w-6 h-6 cursor-pointer" aria-hidden="true" />
<span className="sr-only">{t("auth.signout")}</span>
</button>
+2 -1
View File
@@ -32,6 +32,7 @@ describe("components/toggles/signout", () => {
const { getByRole } = render(<SignOut />);
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" });
});
});
+3 -2
View File
@@ -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));
}
}
+12
View File
@@ -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";
+43 -5
View File
@@ -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 }) {
<div className="rounded-2xl border border-white/60 bg-white/70 p-6 shadow-lg shadow-black/5 dark:border-white/10 dark:bg-slate-900/70">
<h2 className="text-lg font-semibold text-gray-900 dark:text-slate-100">Sign in</h2>
<div className="mt-6 space-y-3">
{hasPasswordProvider && (
{redirecting && (
<p className="text-sm text-gray-600 dark:text-slate-300">
Redirecting to {oidcProvider?.name}&hellip;
</p>
)}
{!redirecting && hasPasswordProvider && (
<form
className="space-y-3"
onSubmit={async (event) => {
@@ -173,7 +210,8 @@ export default function SignIn({ providers, settings }) {
</button>
</form>
)}
{!hasPasswordProvider &&
{!redirecting &&
!hasPasswordProvider &&
Object.values(providers).map((provider) => (
<button
key={provider.id}
@@ -218,6 +256,6 @@ export async function getServerSideProps(context) {
]),
);
return {
props: { providers, settings },
props: { providers, settings, autoLogin: process.env.HOMEPAGE_OIDC_AUTO_LOGIN === "true" },
};
}