diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 52504faede..a6d4e3102a 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -395,7 +395,7 @@ router_settings: | ATHINA_API_KEY | API key for Athina service | ATHINA_BASE_URL | Base URL for Athina service (defaults to `https://log.athina.ai`) | AUTH_STRATEGY | Strategy used for authentication (e.g., OAuth, API key) -| AUTO_REDIRECT_UI_LOGIN_TO_SSO | Flag to enable automatic redirect of UI login page to SSO when SSO is configured. Default is **true** +| AUTO_REDIRECT_UI_LOGIN_TO_SSO | Flag to enable automatic redirect of UI login page to SSO when SSO is configured. Default is **false** | AUDIO_SPEECH_CHUNK_SIZE | Chunk size for audio speech processing. Default is 1024 | ANTHROPIC_API_KEY | API key for Anthropic service | ANTHROPIC_API_BASE | Base URL for Anthropic API. Default is https://api.anthropic.com diff --git a/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py b/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py index 3cbca27ce0..955067b486 100644 --- a/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py +++ b/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py @@ -19,13 +19,15 @@ async def get_ui_config(): from litellm.proxy.utils import get_proxy_base_url, get_server_root_path auto_redirect_ui_login_to_sso = ( - os.getenv("AUTO_REDIRECT_UI_LOGIN_TO_SSO", "true").lower() == "true" + os.getenv("AUTO_REDIRECT_UI_LOGIN_TO_SSO", "false").lower() == "true" ) admin_ui_disabled = os.getenv("DISABLE_ADMIN_UI", "false").lower() == "true" + sso_configured = _has_user_setup_sso() return UiDiscoveryEndpoints( server_root_path=get_server_root_path(), proxy_base_url=get_proxy_base_url(), - auto_redirect_to_sso=_has_user_setup_sso() and auto_redirect_ui_login_to_sso, + auto_redirect_to_sso=sso_configured and auto_redirect_ui_login_to_sso, admin_ui_disabled=admin_ui_disabled, + sso_configured=sso_configured, ) diff --git a/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py b/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py index dc167667bc..4a4cdaa2ba 100644 --- a/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py +++ b/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py @@ -8,3 +8,4 @@ class UiDiscoveryEndpoints(BaseModel): proxy_base_url: Optional[str] auto_redirect_to_sso: bool admin_ui_disabled: bool + sso_configured: bool diff --git a/tests/test_litellm/proxy/discovery_endpoints/test_ui_discovery_endpoints.py b/tests/test_litellm/proxy/discovery_endpoints/test_ui_discovery_endpoints.py index 88d31e993d..9d0c771e1d 100644 --- a/tests/test_litellm/proxy/discovery_endpoints/test_ui_discovery_endpoints.py +++ b/tests/test_litellm/proxy/discovery_endpoints/test_ui_discovery_endpoints.py @@ -31,6 +31,7 @@ def test_ui_discovery_endpoints_with_defaults(): assert data["proxy_base_url"] is None assert data["auto_redirect_to_sso"] is False assert data["admin_ui_disabled"] is False + assert data["sso_configured"] is False def test_ui_discovery_endpoints_with_custom_server_root_path(): @@ -50,6 +51,7 @@ def test_ui_discovery_endpoints_with_custom_server_root_path(): assert data["server_root_path"] == "/litellm" assert data["proxy_base_url"] is None assert data["auto_redirect_to_sso"] is False + assert data["sso_configured"] is False def test_ui_discovery_endpoints_with_proxy_base_url_when_set(): @@ -69,6 +71,7 @@ def test_ui_discovery_endpoints_with_proxy_base_url_when_set(): assert data["server_root_path"] == "/" assert data["proxy_base_url"] == "https://proxy.example.com" assert data["auto_redirect_to_sso"] is False + assert data["sso_configured"] is False def test_ui_discovery_endpoints_with_sso_configured_and_auto_redirect_enabled(): @@ -88,6 +91,30 @@ def test_ui_discovery_endpoints_with_sso_configured_and_auto_redirect_enabled(): assert data["server_root_path"] == "/litellm" assert data["proxy_base_url"] == "https://proxy.example.com" assert data["auto_redirect_to_sso"] is True + assert data["sso_configured"] is True + + +def test_ui_discovery_endpoints_with_sso_configured_and_auto_redirect_not_set_defaults_to_false(): + """When SSO is configured but AUTO_REDIRECT_UI_LOGIN_TO_SSO is not set, defaults to False.""" + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + with patch("litellm.proxy.utils.get_server_root_path", return_value="/litellm"), \ + patch("litellm.proxy.utils.get_proxy_base_url", return_value="https://proxy.example.com"), \ + patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=True), \ + patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False): + # Ensure AUTO_REDIRECT_UI_LOGIN_TO_SSO is not set (simulate default) + os.environ.pop("AUTO_REDIRECT_UI_LOGIN_TO_SSO", None) + + response = client.get("/.well-known/litellm-ui-config") + + assert response.status_code == 200 + data = response.json() + assert data["server_root_path"] == "/litellm" + assert data["proxy_base_url"] == "https://proxy.example.com" + assert data["auto_redirect_to_sso"] is False + assert data["sso_configured"] is True def test_ui_discovery_endpoints_with_sso_configured_but_auto_redirect_disabled(): @@ -107,6 +134,7 @@ def test_ui_discovery_endpoints_with_sso_configured_but_auto_redirect_disabled() assert data["server_root_path"] == "/litellm" assert data["proxy_base_url"] == "https://proxy.example.com" assert data["auto_redirect_to_sso"] is False + assert data["sso_configured"] is True def test_ui_discovery_endpoints_with_sso_not_configured_but_auto_redirect_enabled(): @@ -126,6 +154,7 @@ def test_ui_discovery_endpoints_with_sso_not_configured_but_auto_redirect_enable assert data["server_root_path"] == "/" assert data["proxy_base_url"] is None assert data["auto_redirect_to_sso"] is False + assert data["sso_configured"] is False def test_ui_discovery_endpoints_both_routes_return_same_data(): @@ -164,6 +193,7 @@ def test_ui_discovery_endpoints_with_admin_ui_disabled(): assert data["proxy_base_url"] is None assert data["auto_redirect_to_sso"] is False assert data["admin_ui_disabled"] is True + assert data["sso_configured"] is False def test_ui_discovery_endpoints_with_admin_ui_enabled(): @@ -184,4 +214,5 @@ def test_ui_discovery_endpoints_with_admin_ui_enabled(): assert data["proxy_base_url"] is None assert data["auto_redirect_to_sso"] is False assert data["admin_ui_disabled"] is False + assert data["sso_configured"] is False diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiConfig/useUIConfig.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiConfig/useUIConfig.test.ts index 6429aeafb5..aba5dddf13 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiConfig/useUIConfig.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiConfig/useUIConfig.test.ts @@ -23,6 +23,7 @@ vi.mock("../common/queryKeysFactory", () => ({ // Mock data const mockUIConfig: LiteLLMWellKnownUiConfig = { + sso_configured: true, server_root_path: "/api", proxy_base_url: "https://proxy.example.com", auto_redirect_to_sso: true, @@ -99,6 +100,7 @@ describe("useUIConfig", () => { server_root_path: "/v1", proxy_base_url: null, auto_redirect_to_sso: false, + sso_configured: false, admin_ui_disabled: true, }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts index ef4a779b50..76a3129d6d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts @@ -90,6 +90,7 @@ describe("useAuthorized", () => { proxy_base_url: null, auto_redirect_to_sso: false, admin_ui_disabled: false, + sso_configured: false, }); const decodedPayload = { @@ -131,6 +132,7 @@ describe("useAuthorized", () => { proxy_base_url: null, auto_redirect_to_sso: false, admin_ui_disabled: false, + sso_configured: false, }); decodeTokenMock.mockReturnValue(null); @@ -155,6 +157,7 @@ describe("useAuthorized", () => { proxy_base_url: null, auto_redirect_to_sso: false, admin_ui_disabled: true, + sso_configured: false, }); const decodedPayload = { @@ -190,6 +193,7 @@ describe("useAuthorized", () => { proxy_base_url: null, auto_redirect_to_sso: false, admin_ui_disabled: false, + sso_configured: false, }); decodeTokenMock.mockReturnValue(null); @@ -212,6 +216,7 @@ describe("useAuthorized", () => { proxy_base_url: null, auto_redirect_to_sso: false, admin_ui_disabled: false, + sso_configured: false, }); const decodedPayload = { diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx index 7983451260..ad2dde2da8 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx @@ -64,7 +64,12 @@ describe("LoginPage", () => { it("should render", async () => { (useUIConfig as ReturnType).mockReturnValue({ - data: { auto_redirect_to_sso: false, server_root_path: "/", proxy_base_url: null }, + data: { + auto_redirect_to_sso: false, + server_root_path: "/", + proxy_base_url: null, + sso_configured: false, + }, isLoading: false, }); (getCookie as ReturnType).mockReturnValue(null); @@ -84,7 +89,12 @@ describe("LoginPage", () => { it("should call router.replace to dashboard when jwt is valid", async () => { const validToken = "valid-token"; (useUIConfig as ReturnType).mockReturnValue({ - data: { auto_redirect_to_sso: false, server_root_path: "/", proxy_base_url: null }, + data: { + auto_redirect_to_sso: false, + server_root_path: "/", + proxy_base_url: null, + sso_configured: false, + }, isLoading: false, }); (getCookie as ReturnType).mockReturnValue(validToken); @@ -105,7 +115,12 @@ describe("LoginPage", () => { it("should call router.push to SSO when jwt is invalid and auto_redirect_to_sso is true", async () => { const invalidToken = "invalid-token"; (useUIConfig as ReturnType).mockReturnValue({ - data: { auto_redirect_to_sso: true, server_root_path: "/", proxy_base_url: null }, + data: { + auto_redirect_to_sso: true, + server_root_path: "/", + proxy_base_url: null, + sso_configured: true, + }, isLoading: false, }); (getCookie as ReturnType).mockReturnValue(invalidToken); @@ -126,7 +141,12 @@ describe("LoginPage", () => { it("should not call router when jwt is invalid and auto_redirect_to_sso is false", async () => { const invalidToken = "invalid-token"; (useUIConfig as ReturnType).mockReturnValue({ - data: { auto_redirect_to_sso: false, server_root_path: "/", proxy_base_url: null }, + data: { + auto_redirect_to_sso: false, + server_root_path: "/", + proxy_base_url: null, + sso_configured: false, + }, isLoading: false, }); (getCookie as ReturnType).mockReturnValue(invalidToken); @@ -150,7 +170,12 @@ describe("LoginPage", () => { it("should send user to dashboard when jwt is valid even if auto_redirect_to_sso is true", async () => { const validToken = "valid-token"; (useUIConfig as ReturnType).mockReturnValue({ - data: { auto_redirect_to_sso: true, server_root_path: "/", proxy_base_url: null }, + data: { + auto_redirect_to_sso: true, + server_root_path: "/", + proxy_base_url: null, + sso_configured: true, + }, isLoading: false, }); (getCookie as ReturnType).mockReturnValue(validToken); @@ -172,7 +197,12 @@ describe("LoginPage", () => { it("should show alert when admin_ui_disabled is true", async () => { (useUIConfig as ReturnType).mockReturnValue({ - data: { admin_ui_disabled: true, server_root_path: "/", proxy_base_url: null }, + data: { + admin_ui_disabled: true, + server_root_path: "/", + proxy_base_url: null, + sso_configured: false, + }, isLoading: false, }); (getCookie as ReturnType).mockReturnValue(null); @@ -192,4 +222,60 @@ describe("LoginPage", () => { expect(mockPush).not.toHaveBeenCalled(); expect(mockReplace).not.toHaveBeenCalled(); }); + + it("should show Login with SSO button when sso_configured is true", async () => { + (useUIConfig as ReturnType).mockReturnValue({ + data: { + auto_redirect_to_sso: false, + server_root_path: "/", + proxy_base_url: null, + sso_configured: true, + }, + isLoading: false, + }); + (getCookie as ReturnType).mockReturnValue(null); + (isJwtExpired as ReturnType).mockReturnValue(true); + + const queryClient = createQueryClient(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Login" })).toBeInTheDocument(); + }); + + expect(screen.getByRole("button", { name: "Login with SSO" })).toBeInTheDocument(); + }); + + it("should show disabled Login with SSO button with popover when sso_configured is false", async () => { + (useUIConfig as ReturnType).mockReturnValue({ + data: { + auto_redirect_to_sso: false, + server_root_path: "/", + proxy_base_url: null, + sso_configured: false, + }, + isLoading: false, + }); + (getCookie as ReturnType).mockReturnValue(null); + (isJwtExpired as ReturnType).mockReturnValue(true); + + const queryClient = createQueryClient(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Login" })).toBeInTheDocument(); + }); + + const ssoButton = screen.getByRole("button", { name: "Login with SSO" }); + expect(ssoButton).toBeInTheDocument(); + expect(ssoButton).toBeDisabled(); + }); }); diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.tsx index 620cb41dfe..a05fa4e214 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.tsx @@ -8,7 +8,7 @@ import { getCookie } from "@/utils/cookieUtils"; import { isJwtExpired } from "@/utils/jwtUtils"; import { InfoCircleOutlined } from "@ant-design/icons"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Alert, Button, Card, Form, Input, Space, Typography } from "antd"; +import { Alert, Button, Card, Form, Input, Popover, Space, Typography } from "antd"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -179,8 +179,39 @@ function LoginPageContent() { {isLoginLoading ? "Logging in..." : "Login"} + + {!uiConfig?.sso_configured ? ( + + + + ) : ( + + )} + + {uiConfig?.sso_configured && ( + Single Sign-On (SSO) is enabled. LiteLLM no longer automatically redirects to the SSO login flow upon loading this page. To re-enable auto-redirect-to-SSO, set AUTO_REDIRECT_UI_LOGIN_TO_SSO=true in your environment configuration.} + /> + )} ); diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx index 0a5cd17e57..ee59ac84ec 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx @@ -71,6 +71,7 @@ describe("ModelHubTable", () => { proxy_base_url: "http://localhost:4000", auto_redirect_to_sso: false, admin_ui_disabled: false, + sso_configured: false, }); vi.mocked(networking.modelHubPublicModelsCall).mockResolvedValue([]); vi.mocked(networking.getUiSettings).mockResolvedValue({ @@ -140,6 +141,7 @@ describe("ModelHubTable", () => { proxy_base_url: "http://localhost:4000", auto_redirect_to_sso: false, admin_ui_disabled: false, + sso_configured: false, }); modelHubPublicModelsCallMock.mockResolvedValue([]); vi.mocked(networking.getUiSettings).mockResolvedValue({ diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 277e66ec13..bee0930dda 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -259,6 +259,7 @@ export interface LiteLLMWellKnownUiConfig { proxy_base_url: string | null; auto_redirect_to_sso: boolean; admin_ui_disabled: boolean; + sso_configured: boolean; } export interface CredentialsResponse {