mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-23 22:27:10 +00:00
Merge pull request #20908 from BerriAI/litellm_ui_login_sso_redir
[Feature] UI - Login: New Login With SSO Button
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -8,3 +8,4 @@ class UiDiscoveryEndpoints(BaseModel):
|
||||
proxy_base_url: Optional[str]
|
||||
auto_redirect_to_sso: bool
|
||||
admin_ui_disabled: bool
|
||||
sso_configured: bool
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -64,7 +64,12 @@ describe("LoginPage", () => {
|
||||
|
||||
it("should render", async () => {
|
||||
(useUIConfig as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(validToken);
|
||||
@@ -172,7 +197,12 @@ describe("LoginPage", () => {
|
||||
|
||||
it("should show alert when admin_ui_disabled is true", async () => {
|
||||
(useUIConfig as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue({
|
||||
data: {
|
||||
auto_redirect_to_sso: false,
|
||||
server_root_path: "/",
|
||||
proxy_base_url: null,
|
||||
sso_configured: true,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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<typeof vi.fn>).mockReturnValue({
|
||||
data: {
|
||||
auto_redirect_to_sso: false,
|
||||
server_root_path: "/",
|
||||
proxy_base_url: null,
|
||||
sso_configured: false,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(getCookie as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(isJwtExpired as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Login" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const ssoButton = screen.getByRole("button", { name: "Login with SSO" });
|
||||
expect(ssoButton).toBeInTheDocument();
|
||||
expect(ssoButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
{!uiConfig?.sso_configured ? (
|
||||
<Popover
|
||||
content="Please configure SSO to log in with SSO."
|
||||
trigger="hover"
|
||||
>
|
||||
<Button disabled block size="large">
|
||||
Login with SSO
|
||||
</Button>
|
||||
</Popover>
|
||||
) : (
|
||||
<Button
|
||||
disabled={isLoginLoading}
|
||||
onClick={() =>
|
||||
router.push(`${getProxyBaseUrl()}/sso/key/generate`)
|
||||
}
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
Login with SSO
|
||||
</Button>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
{uiConfig?.sso_configured && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
message={<Text>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 <Text code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</Text> in your environment configuration.</Text>}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user