diff --git a/docs/my-website/docs/proxy/admin_ui_sso.md b/docs/my-website/docs/proxy/admin_ui_sso.md index dba563a327b..4984cac2d0d 100644 --- a/docs/my-website/docs/proxy/admin_ui_sso.md +++ b/docs/my-website/docs/proxy/admin_ui_sso.md @@ -73,8 +73,21 @@ GOOGLE_CLIENT_SECRET= ```shell MICROSOFT_CLIENT_ID="84583a4d-" MICROSOFT_CLIENT_SECRET="nbk8Q~" -MICROSOFT_TENANT="5a39737 +MICROSOFT_TENANT="5a39737" ``` + +**Optional: Custom Microsoft SSO Endpoints** + +If you need to use custom Microsoft SSO endpoints (e.g., for a custom identity provider, sovereign cloud, or proxy), you can override the default endpoints: + +```shell +MICROSOFT_AUTHORIZATION_ENDPOINT="https://your-custom-url.com/oauth2/v2.0/authorize" +MICROSOFT_TOKEN_ENDPOINT="https://your-custom-url.com/oauth2/v2.0/token" +MICROSOFT_USERINFO_ENDPOINT="https://your-custom-graph-api.com/v1.0/me" +``` + +If these are not set, the default Microsoft endpoints are used based on your tenant. + - Set Redirect URI on your App Registration on https://portal.azure.com/ - Set a redirect url = `/sso/callback` ```shell diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 42494c1e5f5..e9f9fb37a25 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -771,10 +771,13 @@ router_settings: | MINIMUM_PROMPT_CACHE_TOKEN_COUNT | Minimum token count for caching a prompt. Default is 1024 | MISTRAL_API_BASE | Base URL for Mistral API. Default is https://api.mistral.ai | MISTRAL_API_KEY | API key for Mistral API +| MICROSOFT_AUTHORIZATION_ENDPOINT | Custom authorization endpoint URL for Microsoft SSO (overrides default Microsoft OAuth authorization endpoint) | MICROSOFT_CLIENT_ID | Client ID for Microsoft services | MICROSOFT_CLIENT_SECRET | Client secret for Microsoft services -| MICROSOFT_TENANT | Tenant ID for Microsoft Azure | MICROSOFT_SERVICE_PRINCIPAL_ID | Service Principal ID for Microsoft Enterprise Application. (This is an advanced feature if you want litellm to auto-assign members to Litellm Teams based on their Microsoft Entra ID Groups) +| MICROSOFT_TENANT | Tenant ID for Microsoft Azure +| MICROSOFT_TOKEN_ENDPOINT | Custom token endpoint URL for Microsoft SSO (overrides default Microsoft OAuth token endpoint) +| MICROSOFT_USERINFO_ENDPOINT | Custom userinfo endpoint URL for Microsoft SSO (overrides default Microsoft Graph userinfo endpoint) | NO_DOCS | Flag to disable Swagger UI documentation | NO_REDOC | Flag to disable Redoc documentation | NO_PROXY | List of addresses to bypass proxy diff --git a/litellm/proxy/management_endpoints/sso/__init__.py b/litellm/proxy/management_endpoints/sso/__init__.py new file mode 100644 index 00000000000..8144e7c53ff --- /dev/null +++ b/litellm/proxy/management_endpoints/sso/__init__.py @@ -0,0 +1,12 @@ +""" +SSO (Single Sign-On) related modules for LiteLLM Proxy. + +This package contains custom SSO implementations and utilities. +""" + +from litellm.proxy.management_endpoints.sso.custom_microsoft_sso import ( + CustomMicrosoftSSO, +) + +__all__ = ["CustomMicrosoftSSO"] + diff --git a/litellm/proxy/management_endpoints/sso/custom_microsoft_sso.py b/litellm/proxy/management_endpoints/sso/custom_microsoft_sso.py new file mode 100644 index 00000000000..61b3a8231ac --- /dev/null +++ b/litellm/proxy/management_endpoints/sso/custom_microsoft_sso.py @@ -0,0 +1,91 @@ +""" +Custom Microsoft SSO class that allows overriding default Microsoft endpoints. + +This module provides a subclass of fastapi_sso's MicrosoftSSO that allows +custom authorization, token, and userinfo endpoints to be specified via environment +variables. + +Environment Variables: +- MICROSOFT_AUTHORIZATION_ENDPOINT: Custom authorization endpoint URL +- MICROSOFT_TOKEN_ENDPOINT: Custom token endpoint URL +- MICROSOFT_USERINFO_ENDPOINT: Custom userinfo endpoint URL + +If these are not set, the default Microsoft endpoints are used. +""" + +import os +from typing import List, Optional, Union + +import pydantic +from fastapi_sso.sso.base import DiscoveryDocument +from fastapi_sso.sso.microsoft import MicrosoftSSO + +from litellm._logging import verbose_proxy_logger + + +class CustomMicrosoftSSO(MicrosoftSSO): + """ + Microsoft SSO subclass that allows overriding default endpoints via environment variables. + + Supports: + - MICROSOFT_AUTHORIZATION_ENDPOINT + - MICROSOFT_TOKEN_ENDPOINT + - MICROSOFT_USERINFO_ENDPOINT + """ + + def __init__( + self, + client_id: str, + client_secret: str, + redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None, + allow_insecure_http: bool = False, + scope: Optional[List[str]] = None, + tenant: Optional[str] = None, + ): + super().__init__( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + allow_insecure_http=allow_insecure_http, + scope=scope, + tenant=tenant, + ) + + async def get_discovery_document(self) -> DiscoveryDocument: + """ + Override to support custom endpoints via environment variables. + Falls back to default Microsoft endpoints if not set. + """ + custom_authorization_endpoint = os.getenv( + "MICROSOFT_AUTHORIZATION_ENDPOINT", None + ) + custom_token_endpoint = os.getenv("MICROSOFT_TOKEN_ENDPOINT", None) + custom_userinfo_endpoint = os.getenv("MICROSOFT_USERINFO_ENDPOINT", None) + + # Use custom endpoints if set, otherwise use defaults + authorization_endpoint = ( + custom_authorization_endpoint + or f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/authorize" + ) + token_endpoint = ( + custom_token_endpoint + or f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token" + ) + userinfo_endpoint = ( + custom_userinfo_endpoint or f"https://graph.microsoft.com/{self.version}/me" + ) + + if custom_authorization_endpoint or custom_token_endpoint or custom_userinfo_endpoint: + verbose_proxy_logger.debug( + f"Using custom Microsoft SSO endpoints - " + f"authorization: {authorization_endpoint}, " + f"token: {token_endpoint}, " + f"userinfo: {userinfo_endpoint}" + ) + + return DiscoveryDocument( + authorization_endpoint=authorization_endpoint, + token_endpoint=token_endpoint, + userinfo_endpoint=userinfo_endpoint, + ) + diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index dc976e1ce64..5b3ebbf1c27 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -57,6 +57,7 @@ ) from litellm.proxy.common_utils.html_forms.ui_login import html_form from litellm.proxy.management_endpoints.internal_user_endpoints import new_user +from litellm.proxy.management_endpoints.sso import CustomMicrosoftSSO from litellm.proxy.management_endpoints.sso_helper_utils import ( check_is_admin_only_access, has_admin_ui_access, @@ -341,7 +342,7 @@ def generic_response_convertor( if role_mappings is not None and role_mappings.provider.lower() in ["generic", "okta"]: # Use role_mappings to determine role from groups group_claim = role_mappings.group_claim - user_groups_raw = get_nested_value(response, group_claim) + user_groups_raw: Any = get_nested_value(response, group_claim) # Handle different formats: could be a list, string (comma-separated), or single value user_groups: List[str] = [] @@ -1450,8 +1451,6 @@ async def get_sso_login_redirect( return await google_sso.get_login_redirect(state=state) # Microsoft SSO Auth elif microsoft_client_id is not None: - from fastapi_sso.sso.microsoft import MicrosoftSSO - microsoft_client_secret = os.getenv("MICROSOFT_CLIENT_SECRET", None) microsoft_tenant = os.getenv("MICROSOFT_TENANT", None) if microsoft_client_secret is None: @@ -1461,7 +1460,7 @@ async def get_sso_login_redirect( param="MICROSOFT_CLIENT_SECRET", code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - microsoft_sso = MicrosoftSSO( + microsoft_sso = CustomMicrosoftSSO( client_id=microsoft_client_id, client_secret=microsoft_client_secret, tenant=microsoft_tenant, @@ -2277,8 +2276,6 @@ async def get_microsoft_callback_response( Args: return_raw_sso_response: If True, return the raw SSO response """ - from fastapi_sso.sso.microsoft import MicrosoftSSO - microsoft_client_secret = os.getenv("MICROSOFT_CLIENT_SECRET", None) microsoft_tenant = os.getenv("MICROSOFT_TENANT", None) if microsoft_client_secret is None: @@ -2295,7 +2292,7 @@ async def get_microsoft_callback_response( param="MICROSOFT_TENANT", code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - microsoft_sso = MicrosoftSSO( + microsoft_sso = CustomMicrosoftSSO( client_id=microsoft_client_id, client_secret=microsoft_client_secret, tenant=microsoft_tenant, diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 829e76108c4..be9d193e3f1 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -18,6 +18,7 @@ import litellm from litellm.proxy._types import LiteLLM_UserTable, NewTeamRequest, NewUserResponse from litellm.proxy.auth.handle_jwt import JWTHandler +from litellm.proxy.management_endpoints.sso import CustomMicrosoftSSO from litellm.proxy.management_endpoints.types import CustomOpenID from litellm.proxy.management_endpoints.ui_sso import ( GoogleSSOHandler, @@ -3386,3 +3387,126 @@ async def test_sso_readiness_generic_configurations( ) finally: app.dependency_overrides.clear() + + +class TestCustomMicrosoftSSO: + """Tests for CustomMicrosoftSSO class.""" + + @pytest.mark.asyncio + async def test_custom_microsoft_sso_uses_default_endpoints_when_no_env_vars(self): + """ + Test that CustomMicrosoftSSO uses default Microsoft endpoints + when no custom environment variables are set. + """ + # Ensure no custom endpoints are set + for key in [ + "MICROSOFT_AUTHORIZATION_ENDPOINT", + "MICROSOFT_TOKEN_ENDPOINT", + "MICROSOFT_USERINFO_ENDPOINT", + ]: + os.environ.pop(key, None) + + sso = CustomMicrosoftSSO( + client_id="test-client-id", + client_secret="test-client-secret", + tenant="test-tenant", + redirect_uri="http://localhost:4000/sso/callback", + ) + + discovery = await sso.get_discovery_document() + + assert discovery["authorization_endpoint"] == "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/authorize" + assert discovery["token_endpoint"] == "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/token" + assert discovery["userinfo_endpoint"] == "https://graph.microsoft.com/v1.0/me" + + @pytest.mark.asyncio + async def test_custom_microsoft_sso_uses_custom_endpoints_when_env_vars_set(self): + """ + Test that CustomMicrosoftSSO uses custom endpoints + when environment variables are set. + """ + custom_auth_endpoint = "https://custom.example.com/oauth2/v2.0/authorize" + custom_token_endpoint = "https://custom.example.com/oauth2/v2.0/token" + custom_userinfo_endpoint = "https://custom.example.com/v1.0/me" + + with patch.dict( + os.environ, + { + "MICROSOFT_AUTHORIZATION_ENDPOINT": custom_auth_endpoint, + "MICROSOFT_TOKEN_ENDPOINT": custom_token_endpoint, + "MICROSOFT_USERINFO_ENDPOINT": custom_userinfo_endpoint, + }, + ): + sso = CustomMicrosoftSSO( + client_id="test-client-id", + client_secret="test-client-secret", + tenant="test-tenant", + redirect_uri="http://localhost:4000/sso/callback", + ) + + discovery = await sso.get_discovery_document() + + assert discovery["authorization_endpoint"] == custom_auth_endpoint + assert discovery["token_endpoint"] == custom_token_endpoint + assert discovery["userinfo_endpoint"] == custom_userinfo_endpoint + + @pytest.mark.asyncio + async def test_custom_microsoft_sso_uses_partial_custom_endpoints(self): + """ + Test that CustomMicrosoftSSO uses custom endpoints for those set, + and defaults for others. + """ + custom_auth_endpoint = "https://custom.example.com/oauth2/v2.0/authorize" + + # Clear other env vars first + os.environ.pop("MICROSOFT_TOKEN_ENDPOINT", None) + os.environ.pop("MICROSOFT_USERINFO_ENDPOINT", None) + + with patch.dict( + os.environ, + { + "MICROSOFT_AUTHORIZATION_ENDPOINT": custom_auth_endpoint, + }, + ): + sso = CustomMicrosoftSSO( + client_id="test-client-id", + client_secret="test-client-secret", + tenant="test-tenant", + redirect_uri="http://localhost:4000/sso/callback", + ) + + discovery = await sso.get_discovery_document() + + # Custom auth endpoint + assert discovery["authorization_endpoint"] == custom_auth_endpoint + # Default token and userinfo endpoints + assert discovery["token_endpoint"] == "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/token" + assert discovery["userinfo_endpoint"] == "https://graph.microsoft.com/v1.0/me" + + def test_custom_microsoft_sso_uses_common_tenant_when_none(self): + """ + Test that CustomMicrosoftSSO uses 'common' tenant when tenant is None. + """ + sso = CustomMicrosoftSSO( + client_id="test-client-id", + client_secret="test-client-secret", + tenant=None, + redirect_uri="http://localhost:4000/sso/callback", + ) + + assert sso.tenant == "common" + + def test_custom_microsoft_sso_is_subclass_of_microsoft_sso(self): + """ + Test that CustomMicrosoftSSO is a subclass of MicrosoftSSO. + """ + from fastapi_sso.sso.microsoft import MicrosoftSSO + + sso = CustomMicrosoftSSO( + client_id="test-client-id", + client_secret="test-client-secret", + tenant="test-tenant", + redirect_uri="http://localhost:4000/sso/callback", + ) + + assert isinstance(sso, MicrosoftSSO)