mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-29 07:21:16 -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_ID`
|
||||||
- `HOMEPAGE_OIDC_CLIENT_SECRET`
|
- `HOMEPAGE_OIDC_CLIENT_SECRET`
|
||||||
- Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`)
|
- 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
|
!!! warning
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
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(),
|
getSettingsMock: vi.fn(),
|
||||||
authOptionsMock: vi.fn(),
|
authOptionsMock: vi.fn(),
|
||||||
|
signInMock: vi.fn(),
|
||||||
|
routerQuery: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => ({
|
vi.mock("utils/config/config", () => ({
|
||||||
@@ -20,13 +22,23 @@ vi.mock("pages/api/auth/[...nextauth]", () => ({
|
|||||||
|
|
||||||
vi.mock("next/router", () => ({
|
vi.mock("next/router", () => ({
|
||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
query: {},
|
query: routerQuery,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("next-auth/react", () => ({ signIn: signInMock }));
|
||||||
|
|
||||||
import SignInPage, { getServerSideProps } from "pages/auth/signin";
|
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", () => {
|
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 () => {
|
it("renders an error state when no providers are configured", async () => {
|
||||||
render(
|
render(
|
||||||
<SignInPage
|
<SignInPage
|
||||||
@@ -66,6 +78,53 @@ describe("pages/auth/signin", () => {
|
|||||||
expect(screen.getByRole("button", { name: /login via oidc/i })).toBeInTheDocument();
|
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 () => {
|
it("getServerSideProps returns providers and only public sign-in settings", async () => {
|
||||||
authOptionsMock.mockReturnValueOnce({ providers: [{ id: "foo", name: "Foo", type: "oauth" }] });
|
authOptionsMock.mockReturnValueOnce({ providers: [{ id: "foo", name: "Foo", type: "oauth" }] });
|
||||||
getSettingsMock.mockReturnValueOnce({
|
getSettingsMock.mockReturnValueOnce({
|
||||||
@@ -88,6 +147,7 @@ describe("pages/auth/signin", () => {
|
|||||||
expect(getSettingsMock).toHaveBeenCalled();
|
expect(getSettingsMock).toHaveBeenCalled();
|
||||||
expect(res).toEqual({
|
expect(res).toEqual({
|
||||||
props: {
|
props: {
|
||||||
|
autoLogin: false,
|
||||||
providers: { foo: { id: "foo", name: "Foo", type: "oauth" } },
|
providers: { foo: { id: "foo", name: "Foo", type: "oauth" } },
|
||||||
settings: {
|
settings: {
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
@@ -102,6 +162,28 @@ describe("pages/auth/signin", () => {
|
|||||||
expect(res.props.settings).not.toHaveProperty("layout");
|
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 () => {
|
it("getServerSideProps falls back to no providers when auth options fail to load", async () => {
|
||||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
authOptionsMock.mockImplementationOnce(() => {
|
authOptionsMock.mockImplementationOnce(() => {
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ export default function SignOut() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="signout" className="rounded-full flex align-middle self-center mr-3">
|
<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" />
|
<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>
|
<span className="sr-only">{t("auth.signout")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ describe("components/toggles/signout", () => {
|
|||||||
const { getByRole } = render(<SignOut />);
|
const { getByRole } = render(<SignOut />);
|
||||||
fireEvent.click(getByRole("button"));
|
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 });
|
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";
|
const isPublicAuthPath = pathname.startsWith("/api/healthcheck") || pathname === "/api/config/custom.css";
|
||||||
if (authEnabled && !isPublicAuthPath) {
|
if (authEnabled && !isPublicAuthPath) {
|
||||||
// The MCP API handler authorizes both bearer tokens and Homepage sessions.
|
// 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 });
|
const token = await getToken({ req, secret: authSecret });
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const signInUrl = new URL("/auth/signin", req.url);
|
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));
|
return withPrivateCache(NextResponse.redirect(signInUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,18 @@ describe("middleware", () => {
|
|||||||
expect(String(res.url)).toContain("/auth/signin");
|
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 () => {
|
it("allows requests when auth is enabled and a token is present", async () => {
|
||||||
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||||
process.env.HOMEPAGE_AUTH_SECRET = "secret";
|
process.env.HOMEPAGE_AUTH_SECRET = "secret";
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { BiShieldQuarter } from "react-icons/bi";
|
|||||||
import { getSettings } from "utils/config/config";
|
import { getSettings } from "utils/config/config";
|
||||||
|
|
||||||
const PUBLIC_SIGN_IN_SETTINGS = ["theme", "color", "title", "background", "backgroundOpacity"];
|
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 router = useRouter();
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const theme = settings?.theme || "dark";
|
const theme = settings?.theme || "dark";
|
||||||
@@ -20,6 +22,24 @@ export default function SignIn({ providers, settings }) {
|
|||||||
}, [router.query?.callbackUrl]);
|
}, [router.query?.callbackUrl]);
|
||||||
const error = router.query?.error;
|
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 backgroundImage = "";
|
||||||
let opacity = settings?.backgroundOpacity ?? 0;
|
let opacity = settings?.backgroundOpacity ?? 0;
|
||||||
let backgroundBlur = false;
|
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">
|
<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>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-slate-100">Sign in</h2>
|
||||||
<div className="mt-6 space-y-3">
|
<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
|
<form
|
||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
onSubmit={async (event) => {
|
onSubmit={async (event) => {
|
||||||
@@ -173,7 +198,8 @@ export default function SignIn({ providers, settings }) {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{!hasPasswordProvider &&
|
{!redirecting &&
|
||||||
|
!hasPasswordProvider &&
|
||||||
Object.values(providers).map((provider) => (
|
Object.values(providers).map((provider) => (
|
||||||
<button
|
<button
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
@@ -217,7 +243,14 @@ export async function getServerSideProps(context) {
|
|||||||
homepageSettings[key],
|
homepageSettings[key],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
// A pending attempt means the previous redirect never established a session
|
||||||
|
const autoLoginAttempted = context.req?.cookies?.[AUTO_LOGIN_COOKIE] === "1";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: { providers, settings },
|
props: {
|
||||||
|
providers,
|
||||||
|
settings,
|
||||||
|
autoLogin: process.env.HOMEPAGE_OIDC_AUTO_LOGIN === "true" && !autoLoginAttempted,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user