fix(sso): honor singular team_id_jwt_field in SSO callback

The SSO callback flow (`generic_response_convertor` → `add_missing_team_member`)
was reading only the plural `team_ids_jwt_field` claim. IdPs that populate the
singular `team_id_jwt_field` instead (Okta/Auth0 group → primary team) had their
users created via SSO with `teams: []` — never assigned to the team named in the
JWT, regardless of how the user_metadata changed across logins.

Adds `JWTHandler.get_all_jwt_team_ids()` returning the deduplicated union of
both claim sources, and threads it through both branches that build `all_teams`
in `generic_response_convertor`. JWT bearer auth path is intentionally
unchanged — pending customer feedback on whether membership reconciliation
there should be moved or stay additive.

Reads the singular team_id_jwt_field claim directly via get_nested_value so
the helper returns only what's actually in the token, avoiding silent
fallback to litellm_jwtauth.team_id_default (which would have been an
undocumented backwards-incompatible change for tenants with team_id_default
configured). Tests pin behavior across both configurations of the default
(team_id_jwt_field set + missing claim, and team_id_jwt_field unset).
This commit is contained in:
Ryan Crabbe 2026-05-08 12:25:55 -07:00
parent 144279eb57
commit d32c508ed6
No known key found for this signature in database
3 changed files with 102 additions and 3 deletions

View file

@ -224,6 +224,36 @@ class JWTHandler:
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.
This intentionally does NOT consult ``team_id_default``: that fallback
is a property of how the JWT-bearer auth flow resolves a single
request-bound team, not of the token's claims. Callers that want the
default-team behavior should still go through ``get_team_id``.
"""
team_ids: List[str] = list(self.get_team_ids_from_jwt(token))
if self.litellm_jwtauth.team_id_jwt_field is not None:
singular = get_nested_value(
data=token,
key_path=self.litellm_jwtauth.team_id_jwt_field,
default=None,
)
if isinstance(singular, list):
singular = singular[0] if singular else None
if singular and singular not in team_ids:
team_ids.append(singular)
return team_ids
def get_end_user_id(
self, token: dict, default_value: Optional[str]
) -> Optional[str]:

View file

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

View file

@ -1,5 +1,5 @@
from typing import Optional
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -494,6 +494,75 @@ async def test_sync_user_role_and_teams_no_cache_write_when_nothing_changes():
mock_cache.async_set_cache.assert_not_called()
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
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({}) == []
def test_get_all_jwt_team_ids_does_not_use_team_id_default():
"""team_id_default is a JWT-bearer-flow auth-builder fallback, not a token
claim. It must NOT leak into get_all_jwt_team_ids — otherwise SSO logins
would silently start adding users to the default team for any tenant that
has team_id_default configured."""
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",
team_id_default="default-team",
),
)
# team_id claim missing — must not fall back to default-team
assert jwt_handler.get_all_jwt_team_ids({"teams": []}) == []
assert jwt_handler.get_all_jwt_team_ids({}) == []
# only the plural is populated — default still must not be added
assert jwt_handler.get_all_jwt_team_ids({"teams": ["a"]}) == ["a"]
# team_id_jwt_field unset entirely + only default configured: still no default
jwt_handler.update_environment(
prisma_client=None,
user_api_key_cache=MagicMock(),
litellm_jwtauth=LiteLLM_JWTAuth(
team_ids_jwt_field="teams",
team_id_default="default-team",
),
)
assert jwt_handler.get_all_jwt_team_ids({"teams": []}) == []
@pytest.mark.asyncio
async def test_map_jwt_role_to_litellm_role():
"""Test JWT role mapping to LiteLLM roles with various patterns"""