Merge pull request #15351 from BerriAI/litellm_dev_10_08_2025_p3

SSO - support EntraID app roles
This commit is contained in:
Krish Dholakia
2025-10-08 19:04:00 -07:00
committed by GitHub
6 changed files with 180 additions and 6 deletions
@@ -81,6 +81,23 @@ MICROSOFT_TENANT="5a39737
http://localhost:4000/sso/callback
```
**Using App Roles for User Permissions**
You can assign user roles directly from Entra ID using App Roles. LiteLLM will automatically read the app roles from the JWT token and assign the corresponding role to the user.
Supported roles:
- `proxy_admin` - Admin over the platform
- `proxy_admin_viewer` - Can login, view all keys, view all spend (read-only)
- `internal_user` - Normal user. Can login, view spend and depending on team-member permissions - view/create/delete their own keys.
To set up app roles:
1. Navigate to your App Registration on https://portal.azure.com/
2. Go to "App roles" and create a new app role
3. Use one of the supported role names above (e.g., `proxy_admin`)
4. Assign users to these roles in your Enterprise Application
5. When users sign in via SSO, LiteLLM will automatically assign them the corresponding role
</TabItem>
<TabItem value="Generic" label="Generic SSO Provider">
@@ -140,6 +140,54 @@ litellm_settings:
<Image img={require('../../img/msft_default_settings.png')} style={{ width: '900px', height: 'auto' }} />
## 4. Using Entra ID App Roles for User Permissions
You can assign user roles directly from Entra ID using App Roles. LiteLLM will automatically read the app roles from the JWT token during SSO sign-in and assign the corresponding role to the user.
### 4.1 Supported Roles
LiteLLM supports the following app roles (case-insensitive):
- `proxy_admin` - Admin over the entire LiteLLM platform
- `proxy_admin_viewer` - Read-only admin access (can view all keys and spend)
- `org_admin` - Admin over a specific organization (can create teams and users within their org)
- `internal_user` - Standard user (can create/view/delete their own keys and view their own spend)
### 4.2 Create App Roles in Entra ID
1. Navigate to your App Registration on https://portal.azure.com/
2. Go to **App roles** > **Create app role**
3. Configure the app role:
- **Display name**: Proxy Admin (or your preferred display name)
- **Value**: `proxy_admin` (use one of the supported role values above)
- **Description**: Administrator access to LiteLLM proxy
- **Allowed member types**: Users/Groups
4. Click **Apply** to save the role
### 4.3 Assign Users to App Roles
1. Navigate to **Enterprise Applications** on https://portal.azure.com/
2. Select your LiteLLM application
3. Go to **Users and groups** > **Add user/group**
4. Select the user and assign them to one of the app roles you created
### 4.4 Test the Role Assignment
1. Sign in to LiteLLM UI via SSO as a user with an assigned app role
2. LiteLLM will automatically extract the app role from the JWT token
3. The user will be assigned the corresponding LiteLLM role in the database
4. The user's permissions will reflect their assigned role
**How it works:**
- When a user signs in via Microsoft SSO, LiteLLM extracts the `roles` claim from the JWT `id_token`
- If any of the roles match a valid LiteLLM role (case-insensitive), that role is assigned to the user
- If multiple roles are present, LiteLLM uses the first valid role it finds
- This role assignment persists in the LiteLLM database and determines the user's access level
## Video Walkthrough
This walks through setting up sso auto-add for **Microsoft Entra ID**
File diff suppressed because one or more lines are too long
+39 -1
View File
@@ -4,10 +4,48 @@ Types for the management endpoints
Might include fastapi/proxy requirements.txt related imports
"""
from typing import List
from typing import List, Optional, cast
from fastapi_sso.sso.base import OpenID
from litellm.proxy._types import LitellmUserRoles
def is_valid_litellm_user_role(role_str: str) -> bool:
"""
Check if a string is a valid LitellmUserRoles enum value (case-insensitive).
Args:
role_str: String to validate (e.g., "proxy_admin", "PROXY_ADMIN", "internal_user")
Returns:
True if the string matches a valid LitellmUserRoles value, False otherwise
"""
try:
# Use _value2member_map_ for O(1) lookup, case-insensitive
return role_str.lower() in LitellmUserRoles._value2member_map_
except Exception:
return False
def get_litellm_user_role(role_str: str) -> Optional[LitellmUserRoles]:
"""
Convert a string to a LitellmUserRoles enum if valid (case-insensitive).
Args:
role_str: String to convert (e.g., "proxy_admin", "PROXY_ADMIN", "internal_user")
Returns:
LitellmUserRoles enum if valid, None otherwise
"""
try:
# Use _value2member_map_ for O(1) lookup, case-insensitive
result = LitellmUserRoles._value2member_map_.get(role_str.lower())
return cast(Optional[LitellmUserRoles], result)
except Exception:
return None
class CustomOpenID(OpenID):
team_ids: List[str]
user_role: Optional[LitellmUserRoles] = None
+76 -4
View File
@@ -58,7 +58,7 @@ from litellm.proxy.management_endpoints.sso_helper_utils import (
has_admin_ui_access,
)
from litellm.proxy.management_endpoints.team_endpoints import new_team, team_member_add
from litellm.proxy.management_endpoints.types import CustomOpenID
from litellm.proxy.management_endpoints.types import CustomOpenID, get_litellm_user_role
from litellm.proxy.utils import (
PrismaClient,
ProxyLogging,
@@ -277,6 +277,7 @@ def generic_response_convertor(
last_name=response.get(generic_user_last_name_attribute_name),
provider=response.get(generic_provider_attribute_name),
team_ids=all_teams,
user_role=None,
)
@@ -1145,7 +1146,7 @@ class SSOAuthenticationHandler:
) -> str:
"""
Get the redirect URL for SSO
Note: existing_key is not added to the URL to avoid changing the callback URL.
It should be passed via the state parameter instead.
"""
@@ -1348,7 +1349,7 @@ class SSOAuthenticationHandler:
Checks the request 'source' if a cli state token was passed in
This is used to authenticate through the CLI login flow.
The state parameter format is: {PREFIX}:{key}:{existing_key}
- If existing_key is provided, it's included in the state
- The state parameter is used to pass data through the OAuth flow without changing the callback URL
@@ -1673,22 +1674,49 @@ class MicrosoftSSOHandler:
access_token=microsoft_sso.access_token
)
# Extract app roles from the id_token JWT
app_roles = MicrosoftSSOHandler.get_app_roles_from_id_token(
id_token=microsoft_sso.id_token
)
verbose_proxy_logger.debug(f"Extracted app roles from id_token: {app_roles}")
# Combine groups and app roles
user_role: Optional[LitellmUserRoles] = None
if app_roles:
# Check if any app role is a valid LitellmUserRoles
for role_str in app_roles:
role = get_litellm_user_role(role_str)
if role is not None:
user_role = role
verbose_proxy_logger.debug(
f"Found valid LitellmUserRoles '{role.value}' in app_roles"
)
break
verbose_proxy_logger.debug(
f"Combined team_ids (groups + app roles): {user_team_ids}"
)
# if user is trying to get the raw sso response for debugging, return the raw sso response
if return_raw_sso_response:
original_msft_result[MicrosoftSSOHandler.GRAPH_API_RESPONSE_KEY] = (
user_team_ids
)
original_msft_result["app_roles"] = app_roles
return original_msft_result or {}
result = MicrosoftSSOHandler.openid_from_response(
response=original_msft_result,
team_ids=user_team_ids,
user_role=user_role,
)
return result
@staticmethod
def openid_from_response(
response: Optional[dict], team_ids: List[str]
response: Optional[dict],
team_ids: List[str],
user_role: Optional[LitellmUserRoles],
) -> CustomOpenID:
response = response or {}
verbose_proxy_logger.debug(f"Microsoft SSO Callback Response: {response}")
@@ -1700,10 +1728,54 @@ class MicrosoftSSOHandler:
first_name=response.get("givenName"),
last_name=response.get("surname"),
team_ids=team_ids,
user_role=user_role,
)
verbose_proxy_logger.debug(f"Microsoft SSO OpenID Response: {openid_response}")
return openid_response
@staticmethod
def get_app_roles_from_id_token(id_token: Optional[str]) -> List[str]:
"""
Extract app roles from the Microsoft Entra ID (Azure AD) id_token JWT.
App roles are assigned in the Azure AD Enterprise Application and appear
in the 'roles' claim of the id_token.
Args:
id_token (Optional[str]): The JWT id_token from Microsoft SSO
Returns:
List[str]: List of app role names assigned to the user
"""
if not id_token:
verbose_proxy_logger.debug("No id_token provided for app role extraction")
return []
try:
import jwt
# Decode the JWT without signature verification
# (signature is already verified by fastapi_sso)
decoded_token = jwt.decode(id_token, options={"verify_signature": False})
# Extract roles claim from the token
roles = decoded_token.get("roles", [])
if roles and isinstance(roles, list):
verbose_proxy_logger.debug(
f"Found {len(roles)} app role(s) in id_token: {roles}"
)
return roles
else:
verbose_proxy_logger.debug(
"No app roles found in id_token or roles claim is not a list"
)
return []
except Exception as e:
verbose_proxy_logger.error(f"Error extracting app roles from id_token: {e}")
return []
@staticmethod
async def get_user_groups_from_graph_api(
access_token: Optional[str] = None,