mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-24 21:15:46 -07:00
Enhancement: allow OIDC auto-login
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}…
|
||||
</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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user