Enhancement: allow OIDC auto-login

This commit is contained in:
shamoon
2026-09-04 14:23:51 -07:00
parent ddc5adc91c
commit ae8e72a701
7 changed files with 149 additions and 11 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
+85 -3
View File
@@ -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(
<SignInPage
@@ -66,6 +78,53 @@ 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("renders the button when the server disabled auto-login", () => {
render(<SignInPage providers={OIDC_PROVIDERS} settings={SETTINGS} autoLogin={false} />);
expect(signInMock).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /login via homepage oidc/i })).toBeInTheDocument();
});
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 +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(() => {
+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";
+37 -4
View File
@@ -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 }) {
<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 +198,8 @@ export default function SignIn({ providers, settings }) {
</button>
</form>
)}
{!hasPasswordProvider &&
{!redirecting &&
!hasPasswordProvider &&
Object.values(providers).map((provider) => (
<button
key={provider.id}
@@ -217,7 +243,14 @@ export async function getServerSideProps(context) {
homepageSettings[key],
]),
);
// A pending attempt means the previous redirect never established a session
const autoLoginAttempted = context.req?.cookies?.[AUTO_LOGIN_COOKIE] === "1";
return {
props: { providers, settings },
props: {
providers,
settings,
autoLogin: process.env.HOMEPAGE_OIDC_AUTO_LOGIN === "true" && !autoLoginAttempted,
},
};
}