Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions litellm/proxy/auth/handle_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,24 @@ def get_team_ids_from_jwt(self, token: dict) -> List[str]:

return []

def get_all_jwt_team_ids(self, token: dict) -> List[str]:
"""
Return team IDs from both the plural ``team_ids_jwt_field`` and the
singular ``team_id_jwt_field`` claim, as a deduplicated list preserving
plural-first order.

Membership-reconciliation paths (SSO callback, JWT-bearer sync) need
to consider both claim shapes. Reading only the plural field — as
callers historically did — silently dropped users whose IdP populates
the singular field, which is what Okta and Auth0 default to when a
user has a single primary team.
"""
team_ids: List[str] = list(self.get_team_ids_from_jwt(token))
singular = self.get_team_id(token, default_value=None)
if singular and singular not in team_ids:
team_ids.append(singular)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
return team_ids

def get_end_user_id(
self, token: dict, default_value: Optional[str]
) -> Optional[str]:
Expand Down
4 changes: 2 additions & 2 deletions litellm/proxy/management_endpoints/ui_sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ def generic_response_convertor(

all_teams = []
if sso_jwt_handler is not None:
team_ids = sso_jwt_handler.get_team_ids_from_jwt(cast(dict, response))
team_ids = sso_jwt_handler.get_all_jwt_team_ids(cast(dict, response))
all_teams.extend(team_ids)

if team_mappings is not None and team_mappings.team_ids_jwt_field is not None:
Expand All @@ -755,7 +755,7 @@ def generic_response_convertor(
f"Loaded team_ids from DB team_mappings.team_ids_jwt_field='{team_mappings.team_ids_jwt_field}': {team_ids_from_db_mapping}"
)
else:
team_ids = jwt_handler.get_team_ids_from_jwt(cast(dict, response))
team_ids = jwt_handler.get_all_jwt_team_ids(cast(dict, response))
all_teams.extend(team_ids)

# Determine user role based on role_mappings if available
Expand Down
37 changes: 36 additions & 1 deletion tests/test_litellm/proxy/auth/test_handle_jwt.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from typing import Optional
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

Expand Down Expand Up @@ -494,6 +494,41 @@ async def test_sync_user_role_and_teams_no_cache_write_when_nothing_changes():
mock_cache.async_set_cache.assert_not_called()


@pytest.mark.asyncio
async def test_get_all_jwt_team_ids_unions_singular_and_plural():
"""get_all_jwt_team_ids must include the singular team_id_jwt_field claim
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
in addition to the plural team_ids_jwt_field, deduplicated."""
jwt_handler = JWTHandler()
jwt_handler.update_environment(
prisma_client=None,
user_api_key_cache=MagicMock(),
litellm_jwtauth=LiteLLM_JWTAuth(
team_id_jwt_field="team_id",
team_ids_jwt_field="teams",
),
)

# singular only — Okta/Auth0 default shape
assert jwt_handler.get_all_jwt_team_ids({"team_id": "team-low"}) == ["team-low"]

# plural only — pre-fix shape
assert jwt_handler.get_all_jwt_team_ids({"teams": ["a", "b"]}) == ["a", "b"]

# both populated, no overlap
assert jwt_handler.get_all_jwt_team_ids(
{"team_id": "primary", "teams": ["a", "b"]}
) == ["a", "b", "primary"]

# both populated with overlap — singular dedup'd
assert jwt_handler.get_all_jwt_team_ids({"team_id": "a", "teams": ["a", "b"]}) == [
"a",
"b",
]

# neither populated
assert jwt_handler.get_all_jwt_team_ids({}) == []


@pytest.mark.asyncio
async def test_map_jwt_role_to_litellm_role():
"""Test JWT role mapping to LiteLLM roles with various patterns"""
Expand Down
Loading