[Feat] UI SSO - allow fetching role from generic SSO provider (Keycloak) (#17787)

* fix ui SSO

* TestGenericResponseConvertorUserRole

* Assigning User Roles via SSO
This commit is contained in:
Ishaan Jaff 2025-12-10 13:09:28 -08:00 • committed by GitHub
parent 439bb5bfe3
commit 5d456bcdc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 91 additions and 1 deletions

View file

@ -130,6 +130,17 @@ GENERIC_INCLUDE_CLIENT_ID = "false" # some providers enforce that the client_id
GENERIC_SCOPE = "openid profile email" # default scope openid is sometimes not enough to retrieve basic user info like first_name and last_name located in profile scope
```
**Assigning User Roles via SSO**
Use `GENERIC_USER_ROLE_ATTRIBUTE` to specify which attribute in the SSO token contains the user's role. The role value must be one of the following supported LiteLLM roles:
- `proxy_admin` - Admin over the platform
- `proxy_admin_viewer` - Can login, view all keys, view all spend (read-only)
- `internal_user` - Can login, view/create/delete their own keys, view their spend
- `internal_user_view_only` - Can login, view their own keys, view their own spend
Nested attribute paths are supported (e.g., `claims.role` or `attributes.litellm_role`).
- Set Redirect URI, if your provider requires it
- Set a redirect url = `<your proxy base url>/sso/callback`
```shell

View file

@ -265,6 +265,10 @@ def generic_response_convertor(
"GENERIC_USER_PROVIDER_ATTRIBUTE", "provider"
)
generic_user_role_attribute_name = os.getenv(
"GENERIC_USER_ROLE_ATTRIBUTE", "role"
)
verbose_proxy_logger.debug(
f" generic_user_id_attribute_name: {generic_user_id_attribute_name}\n generic_user_email_attribute_name: {generic_user_email_attribute_name}"
)
@ -277,6 +281,17 @@ def generic_response_convertor(
team_ids = jwt_handler.get_team_ids_from_jwt(cast(dict, response))
all_teams.extend(team_ids)
# Extract user role from SSO response
user_role_from_sso = get_nested_value(response, generic_user_role_attribute_name)
user_role: Optional[LitellmUserRoles] = None
if user_role_from_sso is not None:
role = get_litellm_user_role(user_role_from_sso)
if role is not None:
user_role = role
verbose_proxy_logger.debug(
f"Found valid LitellmUserRoles '{role.value}' from SSO attribute '{generic_user_role_attribute_name}'"
)
return CustomOpenID(
id=get_nested_value(response, generic_user_id_attribute_name),
display_name=get_nested_value(
@ -287,7 +302,7 @@ def generic_response_convertor(
last_name=get_nested_value(response, generic_user_last_name_attribute_name),
provider=get_nested_value(response, generic_provider_attribute_name),
team_ids=all_teams,
user_role=None,
user_role=user_role,
)

View file

@ -2438,6 +2438,70 @@ class TestGenericResponseConvertorNestedAttributes:
assert result.display_name == "user-sub-123" # Top-level attribute works
class TestGenericResponseConvertorUserRole:
"""Test generic_response_convertor user role extraction from SSO token"""
def test_generic_response_convertor_extracts_valid_user_role(self):
"""
Test that generic_response_convertor extracts a valid LiteLLM user role
from the SSO token using the GENERIC_USER_ROLE_ATTRIBUTE env var.
"""
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor
mock_jwt_handler = MagicMock(spec=JWTHandler)
mock_jwt_handler.get_team_ids_from_jwt.return_value = []
sso_response = {
"preferred_username": "testuser",
"email": "test@example.com",
"sub": "Test User",
"role": "proxy_admin",
}
with patch.dict(
os.environ,
{"GENERIC_USER_ROLE_ATTRIBUTE": "role"},
):
result = generic_response_convertor(
response=sso_response,
jwt_handler=mock_jwt_handler,
sso_jwt_handler=None,
)
assert isinstance(result, CustomOpenID)
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
def test_generic_response_convertor_ignores_invalid_user_role(self):
"""
Test that generic_response_convertor ignores invalid role values
and sets user_role to None.
"""
from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor
mock_jwt_handler = MagicMock(spec=JWTHandler)
mock_jwt_handler.get_team_ids_from_jwt.return_value = []
sso_response = {
"preferred_username": "testuser",
"email": "test@example.com",
"role": "invalid_role_value",
}
with patch.dict(
os.environ,
{"GENERIC_USER_ROLE_ATTRIBUTE": "role"},
):
result = generic_response_convertor(
response=sso_response,
jwt_handler=mock_jwt_handler,
sso_jwt_handler=None,
)
assert isinstance(result, CustomOpenID)
assert result.user_role is None
class TestGetGenericSSORedirectParams:
"""Test _get_generic_sso_redirect_params state parameter priority handling"""