From 9c481e3ba8ad6d45b2e262f32b2c4b8d1b8e4702 Mon Sep 17 00:00:00 2001 From: Cole McIntosh Date: Wed, 4 Jun 2025 14:09:19 -0600 Subject: [PATCH] Add SSO configuration endpoints and UI integration - Introduced new SSOConfig class to manage SSO settings. - Added endpoints for fetching and updating SSO settings in proxy_setting_endpoints.py. - Created a new __init__.py file to expose the SSO router. - Updated AdminPanel and SSOModals components to handle SSO settings retrieval and updates. - Removed deprecated SSO update logic from AdminPanel. - Enhanced error handling and logging for SSO operations. --- litellm/proxy/ui_crud_endpoints/__init__.py | 3 + .../proxy_setting_endpoints.py | 129 +++++++++++++++++- .../proxy/management_endpoints/ui_sso.py | 62 +++++++++ .../src/components/SSOModals.tsx | 81 ++++++++++- .../src/components/admins.tsx | 33 +---- .../src/components/networking.tsx | 66 +++++++++ 6 files changed, 338 insertions(+), 36 deletions(-) create mode 100644 litellm/proxy/ui_crud_endpoints/__init__.py diff --git a/litellm/proxy/ui_crud_endpoints/__init__.py b/litellm/proxy/ui_crud_endpoints/__init__.py new file mode 100644 index 00000000000..2af6220183b --- /dev/null +++ b/litellm/proxy/ui_crud_endpoints/__init__.py @@ -0,0 +1,3 @@ +from .proxy_setting_endpoints import router as ui_crud_endpoints_router + +__all__ = ["ui_crud_endpoints_router"] \ No newline at end of file diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 6f1b2bfb8c9..23e929d57a5 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -7,7 +7,7 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.types.proxy.management_endpoints.ui_sso import DefaultTeamSSOParams +from litellm.types.proxy.management_endpoints.ui_sso import DefaultTeamSSOParams, SSOConfig router = APIRouter() @@ -169,7 +169,7 @@ async def _get_settings_with_schema( tags=["SSO Settings"], dependencies=[Depends(user_api_key_auth)], ) -async def get_sso_settings(): +async def get_internal_user_settings(): """ Get all SSO settings from the litellm_settings configuration. Returns a structured object with values and descriptions for UI display. @@ -281,3 +281,128 @@ async def update_default_team_settings(settings: DefaultTeamSSOParams): in_memory_var=litellm.default_team_params, success_message="Default team settings updated successfully", ) + + +@router.get( + "/get/sso_settings", + tags=["SSO Settings"], + dependencies=[Depends(user_api_key_auth)], +) +async def get_sso_settings(): + """ + Get all SSO configuration settings from the environment variables. + Returns a structured object with values and descriptions for UI display. + """ + import os + from litellm.proxy.proxy_server import proxy_config + + # Load existing config to get both environment variables and general settings + config = await proxy_config.get_config() + general_settings = config.get("general_settings", {}) or {} + environment_variables = config.get("environment_variables", {}) or {} + + # Get user_email from general_settings + proxy_admin_email = general_settings.get("proxy_admin_email", None) + + # Helper function to get env var value (first from config, then from environment) + def get_env_value(env_var_name: str): + return environment_variables.get(env_var_name) or os.getenv(env_var_name) + + # Get current environment variables for SSO + sso_config = SSOConfig( + google_client_id=get_env_value("GOOGLE_CLIENT_ID"), + google_client_secret=get_env_value("GOOGLE_CLIENT_SECRET"), + microsoft_client_id=get_env_value("MICROSOFT_CLIENT_ID"), + microsoft_client_secret=get_env_value("MICROSOFT_CLIENT_SECRET"), + microsoft_tenant=get_env_value("MICROSOFT_TENANT"), + generic_client_id=get_env_value("GENERIC_CLIENT_ID"), + generic_client_secret=get_env_value("GENERIC_CLIENT_SECRET"), + generic_authorization_endpoint=get_env_value("GENERIC_AUTHORIZATION_ENDPOINT"), + generic_token_endpoint=get_env_value("GENERIC_TOKEN_ENDPOINT"), + generic_userinfo_endpoint=get_env_value("GENERIC_USERINFO_ENDPOINT"), + proxy_base_url=get_env_value("PROXY_BASE_URL"), + user_email=proxy_admin_email, # Get from config instead of environment + ) + + # Get the schema for UI display + from pydantic import TypeAdapter + schema = TypeAdapter(SSOConfig).json_schema(by_alias=True) + + # Convert to dict for response + sso_dict = sso_config.model_dump() + + # Add descriptions to the response + result = { + "values": sso_dict, + "schema": {"description": schema.get("description", ""), "properties": {}}, + } + + # Add property descriptions + for field_name, field_info in schema["properties"].items(): + result["schema"]["properties"][field_name] = { + "description": field_info.get("description", ""), + "type": field_info.get("type", "string"), + } + + return result + + +@router.patch( + "/update/sso_settings", + tags=["SSO Settings"], + dependencies=[Depends(user_api_key_auth)], +) +async def update_sso_settings(sso_config: SSOConfig): + """ + Update SSO configuration by saving to both environment variables and config file. + """ + from litellm.proxy.proxy_server import proxy_config + import os + + # Update environment variables + env_var_mapping = { + 'google_client_id': 'GOOGLE_CLIENT_ID', + 'google_client_secret': 'GOOGLE_CLIENT_SECRET', + 'microsoft_client_id': 'MICROSOFT_CLIENT_ID', + 'microsoft_client_secret': 'MICROSOFT_CLIENT_SECRET', + 'microsoft_tenant': 'MICROSOFT_TENANT', + 'generic_client_id': 'GENERIC_CLIENT_ID', + 'generic_client_secret': 'GENERIC_CLIENT_SECRET', + 'generic_authorization_endpoint': 'GENERIC_AUTHORIZATION_ENDPOINT', + 'generic_token_endpoint': 'GENERIC_TOKEN_ENDPOINT', + 'generic_userinfo_endpoint': 'GENERIC_USERINFO_ENDPOINT', + 'proxy_base_url': 'PROXY_BASE_URL', + } + + # Load existing config + config = await proxy_config.get_config() + + # Update config with new environment variables + if "environment_variables" not in config: + config["environment_variables"] = {} + + # Update general_settings for user_email (admin email) + if "general_settings" not in config: + config["general_settings"] = {} + + # Update environment variables in config and in memory + sso_data = sso_config.model_dump(exclude_none=True) + for field_name, value in sso_data.items(): + if field_name == 'user_email' and value is not None: + # Store user_email in general_settings instead of environment variables + config["general_settings"]["proxy_admin_email"] = value + elif field_name in env_var_mapping and value is not None: + env_var_name = env_var_mapping[field_name] + # Update in config + config["environment_variables"][env_var_name] = value + # Update in runtime environment + os.environ[env_var_name] = value + + # Save the updated config + await proxy_config.save_config(new_config=config) + + return { + "message": "SSO settings updated successfully", + "status": "success", + "settings": sso_data, + } diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py index 0e6f8739fa7..f6838b61703 100644 --- a/litellm/types/proxy/management_endpoints/ui_sso.py +++ b/litellm/types/proxy/management_endpoints/ui_sso.py @@ -31,6 +31,68 @@ class MicrosoftServicePrincipalTeam(TypedDict, total=False): principalId: Optional[str] +class SSOConfig(LiteLLMPydanticObjectBase): + """ + Configuration for SSO environment variables and settings + """ + + # Google SSO + google_client_id: Optional[str] = Field( + default=None, + description="Google OAuth Client ID for SSO authentication", + ) + google_client_secret: Optional[str] = Field( + default=None, + description="Google OAuth Client Secret for SSO authentication", + ) + + # Microsoft SSO + microsoft_client_id: Optional[str] = Field( + default=None, + description="Microsoft OAuth Client ID for SSO authentication", + ) + microsoft_client_secret: Optional[str] = Field( + default=None, + description="Microsoft OAuth Client Secret for SSO authentication", + ) + microsoft_tenant: Optional[str] = Field( + default=None, + description="Microsoft Azure Tenant ID for SSO authentication", + ) + + # Generic/Okta SSO + generic_client_id: Optional[str] = Field( + default=None, + description="Generic OAuth Client ID for SSO authentication (used for Okta and other providers)", + ) + generic_client_secret: Optional[str] = Field( + default=None, + description="Generic OAuth Client Secret for SSO authentication", + ) + generic_authorization_endpoint: Optional[str] = Field( + default=None, + description="Authorization endpoint URL for generic OAuth provider", + ) + generic_token_endpoint: Optional[str] = Field( + default=None, + description="Token endpoint URL for generic OAuth provider", + ) + generic_userinfo_endpoint: Optional[str] = Field( + default=None, + description="User info endpoint URL for generic OAuth provider", + ) + + # Common settings + proxy_base_url: Optional[str] = Field( + default=None, + description="Base URL of the proxy server for SSO redirects", + ) + user_email: Optional[str] = Field( + default=None, + description="Email of the proxy admin user", + ) + + class DefaultTeamSSOParams(LiteLLMPydanticObjectBase): """ Default parameters to apply when a new team is automatically created by LiteLLM via SSO Groups diff --git a/ui/litellm-dashboard/src/components/SSOModals.tsx b/ui/litellm-dashboard/src/components/SSOModals.tsx index 6e0c5740161..47ece2fd3b6 100644 --- a/ui/litellm-dashboard/src/components/SSOModals.tsx +++ b/ui/litellm-dashboard/src/components/SSOModals.tsx @@ -1,6 +1,7 @@ -import React from "react"; -import { Modal, Form, Input, Button as Button2, Select } from "antd"; +import React, { useEffect } from "react"; +import { Modal, Form, Input, Button as Button2, Select, message } from "antd"; import { Text, TextInput } from "@tremor/react"; +import { getSSOSettings, updateSSOSettings } from "./networking"; interface SSOModalsProps { isAddSSOModalVisible: boolean; @@ -11,6 +12,7 @@ interface SSOModalsProps { handleInstructionsOk: () => void; handleInstructionsCancel: () => void; form: any; // Replace with proper Form type if available + accessToken: string | null; } const ssoProviderLogoMap: Record = { @@ -97,7 +99,80 @@ const SSOModals: React.FC = ({ handleInstructionsOk, handleInstructionsCancel, form, + accessToken, }) => { + // Load existing SSO settings when modal opens + useEffect(() => { + const loadSSOSettings = async () => { + if (isAddSSOModalVisible && accessToken) { + try { + const ssoData = await getSSOSettings(accessToken); + console.log("Raw SSO data received:", ssoData); // Debug log + if (ssoData && ssoData.values) { + console.log("SSO values:", ssoData.values); // Debug log + console.log("user_email from API:", ssoData.values.user_email); // Debug log + + // Determine which SSO provider is configured + let selectedProvider = null; + if (ssoData.values.google_client_id) { + selectedProvider = 'google'; + } else if (ssoData.values.microsoft_client_id) { + selectedProvider = 'microsoft'; + } else if (ssoData.values.generic_client_id) { + // Check if it looks like Okta based on endpoints + if (ssoData.values.generic_authorization_endpoint?.includes('okta') || + ssoData.values.generic_authorization_endpoint?.includes('auth0')) { + selectedProvider = 'okta'; + } else { + selectedProvider = 'generic'; + } + } + + // Set form values with existing data + const formValues = { + sso_provider: selectedProvider, + proxy_base_url: ssoData.values.proxy_base_url, + user_email: ssoData.values.user_email, + ...ssoData.values, + }; + + console.log("Setting form values:", formValues); // Debug log + + // Clear form first, then set values with a small delay to ensure proper initialization + form.resetFields(); + setTimeout(() => { + form.setFieldsValue(formValues); + console.log("Form values set, current form values:", form.getFieldsValue()); // Debug log + }, 100); + } + } catch (error) { + console.error("Failed to load SSO settings:", error); + } + } + }; + + loadSSOSettings(); + }, [isAddSSOModalVisible, accessToken, form]); + + // Enhanced form submission handler + const handleFormSubmit = async (formValues: Record) => { + if (!accessToken) { + message.error("No access token available"); + return; + } + + try { + // Save SSO settings using the new API + await updateSSOSettings(accessToken, formValues); + + // Continue with the original flow (show instructions) + handleShowInstructions(formValues); + } catch (error) { + console.error("Failed to save SSO settings:", error); + message.error("Failed to save SSO settings"); + } + }; + // Helper function to render provider fields const renderProviderFields = (provider: string) => { const config = ssoProviderConfigs[provider]; @@ -131,7 +206,7 @@ const SSOModals: React.FC = ({ >
= ({ }; const handleShowInstructions = (formValues: Record) => { - handleAdminCreate(formValues); - handleSSOUpdate(formValues); + console.log("Form submitted with values:", formValues); setIsAddSSOModalVisible(false); setIsInstructionsModalVisible(true); - // Optionally, you can call handleSSOUpdate here with the formValues }; const handleInstructionsOk = () => { @@ -493,33 +490,6 @@ const AdminPanel: React.FC = ({ } }; - const handleSSOUpdate = async (formValues: Record) => { - if (accessToken == null) { - return; - } - - const provider = formValues.sso_provider; - const config = ssoProviderConfigs[provider]; - - const envVars: Record = { - PROXY_BASE_URL: formValues.proxy_base_url, - }; - - // Add provider-specific environment variables using the configuration - if (config) { - Object.entries(config.envVarMap).forEach(([formKey, envKey]) => { - if (formValues[formKey]) { - envVars[envKey] = formValues[formKey]; - } - }); - } - - const payload = { - environment_variables: envVars, - }; - - setCallbacksCall(accessToken, payload); - }; console.log(`admins: ${admins?.length}`); return (
@@ -564,6 +534,7 @@ const AdminPanel: React.FC = ({ handleInstructionsOk={handleInstructionsOk} handleInstructionsCancel={handleInstructionsCancel} form={form} + accessToken={accessToken} /> { + try { + // Construct base URL + let url = proxyBaseUrl + ? `${proxyBaseUrl}/get/sso_settings` + : `/get/sso_settings`; + + console.log("Fetching SSO configuration from:", url); + + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + console.log("Fetched SSO configuration:", data); + return data; + } catch (error) { + console.error("Failed to fetch SSO configuration:", error); + throw error; + } +}; + + +export const updateSSOSettings = async (accessToken: string, settings: Record) => { + try { + // Construct base URL + let url = proxyBaseUrl + ? `${proxyBaseUrl}/update/sso_settings` + : `/update/sso_settings`; + + console.log("Updating SSO configuration:", settings); + + const response = await fetch(url, { + method: "PATCH", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(settings), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + console.log("Updated SSO configuration:", data); + return data; + } catch (error) { + console.error("Failed to update SSO configuration:", error); + throw error; + } +};