[Fix] MSFT SSO - allow setting custom MSFT Base URLs (#18977)

* fix TestCustomMicrosoftSSO

* init CustomMicrosoftSSO

* use CustomMicrosoftSSO

* docs fix

* docs fix
This commit is contained in:
Ishaan Jaff 2026-01-12 18:26:53 -08:00 • committed by GitHub
parent c126cfd8db
commit 21d611554b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 249 additions and 9 deletions

View file

@ -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 = `<your proxy base url>/sso/callback`
```shell

View file

@ -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

View file

@ -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"]

View file

@ -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,
)

View file

@ -57,6 +57,7 @@ from litellm.proxy.common_utils.html_forms.jwt_display_template import (
)
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 @@ class SSOAuthenticationHandler:
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 @@ class SSOAuthenticationHandler:
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 @@ class MicrosoftSSOHandler:
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 @@ class MicrosoftSSOHandler:
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,

View file

@ -18,6 +18,7 @@ sys.path.insert(
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 @@ class TestSSOReadinessEndpoint:
)
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)