Skip to content

fix(sso): pass decoded JWT access token to role mapping during SSO login#24701

Merged
ryan-crabbe-berri merged 1 commit intolitellm_ryan-march-26from
litellm_fix-jwt-role-mappings
Mar 28, 2026
Merged

fix(sso): pass decoded JWT access token to role mapping during SSO login#24701
ryan-crabbe-berri merged 1 commit intolitellm_ryan-march-26from
litellm_fix-jwt-role-mappings

Conversation

@ryan-crabbe-berri
Copy link
Copy Markdown
Collaborator

Summary

During SSO login, bearer tokens (access_token, id_token, refresh_token) are stripped from the OAuth response before role mapping runs (introduced in #22923 for security — preventing token leakage in error messages). Custom role claims encoded inside the JWT access token are lost, so map_jwt_role_to_litellm_role() returns None and the user falls back to internal_user_viewer.

Changes

  • process_sso_jwt_access_token() now returns the decoded JWT payload dict (previously returned None)
  • New _sync_user_role_from_jwt_role_map() — applies jwt_litellm_role_map during SSO login using the decoded access token payload instead of the stripped OAuth response
  • get_generic_sso_response() returns a 3-tuple including the decoded payload
  • Updated all call sites and existing tests for the new return types

Test plan

  • 4 new unit tests in test_ui_sso.py::TestSyncUserRoleFromJwtRoleMap:
    • Stripped response with no role claims → no mapping (bug repro)
    • Decoded access token payload → correct role mapping (fix verification)
    • Existing user with stale role → DB + cache + in-memory update
    • Same role already set → no unnecessary DB write
  • All 168 existing SSO tests pass

During SSO login, bearer tokens are stripped from the OAuth response
before role mapping runs. Custom role claims encoded inside the JWT
access token are lost, so map_jwt_role_to_litellm_role() returns None
and the user falls back to internal_user_viewer.

process_sso_jwt_access_token() now returns the decoded JWT payload, and
a new _sync_user_role_from_jwt_role_map() receives it so
jwt_litellm_role_map works correctly during SSO login.
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 27, 2026 8:53pm

Request Review

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 27, 2026

CLA assistant check
All committers have signed the CLA.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 27, 2026

Greptile Summary

This PR fixes a bug where SSO users configured with jwt_litellm_role_map would always fall back to internal_user_viewer because custom role claims encoded in the JWT access token were stripped from received_response before role mapping ran. The fix threads the decoded JWT payload through the SSO callback chain and introduces _sync_user_role_from_jwt_role_map to apply the role mapping using those claims, mirroring existing behavior for API/JWT users.

Key changes:

  • process_sso_jwt_access_token() now returns the decoded JWT payload dict instead of None.
  • get_generic_sso_response() extended to a 3-tuple (result, received_response, decoded_payload); all call sites and tests updated.
  • New _sync_user_role_from_jwt_role_map() applies jwt_litellm_role_map with the decoded payload, writing the mapped role to user_defined_values, DB, and cache.
  • Four new fully-mocked unit tests cover the bug repro, fix path, DB+cache update, and no-op (same role) cases.
  • Minor style concerns: misleading received_response parameter name, missing TTL on cache write, and an or received_response fallback that could silently revert to the stripped OAuth response.

Confidence Score: 5/5

Safe to merge; the fix is correct, well-tested, and introduces no regressions or security issues.

All findings are P2 (style/quality suggestions): a misleading parameter name, a missing TTL on a cache write, and a minor fallback ambiguity. The core logic is sound — the decoded payload is correctly threaded through the call chain, the role is properly persisted to DB and cache, and downstream apply_user_info_values_to_sso_user_defined_values correctly preserves the mapped role. Existing tests were updated only to handle the new tuple arity (no assertions weakened), and the four new tests comprehensively cover the changed behavior with proper mocks.

litellm/proxy/management_endpoints/ui_sso.py — specifically the _sync_user_role_from_jwt_role_map function's parameter naming and the missing TTL on the cache update.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/ui_sso.py Core SSO fix: process_sso_jwt_access_token now returns the decoded JWT payload; new _sync_user_role_from_jwt_role_map applies jwt_litellm_role_map using that payload; get_generic_sso_response extended to a 3-tuple; minor naming and TTL concerns flagged.
tests/test_litellm/proxy/management_endpoints/test_ui_sso.py Existing tests updated to unpack the new 3-tuple return value (no assertions weakened); four new unit tests cover the bug repro, fix verification, DB+cache update, and no-op cases — all fully mocked with no network calls.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant Callback as auth_callback
    participant GenericSSO as get_generic_sso_response
    participant ProcessJWT as process_sso_jwt_access_token
    participant SyncRole as _sync_user_role_from_jwt_role_map
    participant JWTHandler
    participant DB as PrismaClient
    participant Cache as DualCache

    Browser->>Callback: GET /sso/callback
    Callback->>GenericSSO: request, jwt_handler
    GenericSSO->>ProcessJWT: token string, result
    Note over ProcessJWT: Decode JWT, extract teams + role into result
    ProcessJWT-->>GenericSSO: decoded payload dict (new return value)
    GenericSSO-->>Callback: 3-tuple result, received_response, decoded payload

    Callback->>SSOHandler: handle_callback with decoded payload + jwt_handler
    SSOHandler->>DB: get_user_info_from_db
    DB-->>SSOHandler: user_info

    SSOHandler->>SyncRole: decoded JWT payload
    Note over SyncRole: Check sync_user_role_and_teams and jwt_litellm_role_map config
    SyncRole->>JWTHandler: map_jwt_role_to_litellm_role
    JWTHandler-->>SyncRole: mapped LiteLLM role
    SyncRole->>DB: update user_role in DB
    SyncRole->>Cache: update user cache entry
    SyncRole-->>SSOHandler: user_defined_values updated

    SSOHandler->>SSOHandler: apply_user_info_values_to_sso_user_defined_values
    SSOHandler->>Browser: RedirectResponse with session key
Loading

Reviews (1): Last reviewed commit: "fix(sso): pass decoded JWT access token ..." | Re-trigger Greptile

Comment on lines +1190 to +1197
async def _sync_user_role_from_jwt_role_map(
jwt_handler: Optional[JWTHandler],
received_response: Optional[dict],
user_info: Optional[Union[LiteLLM_UserTable, NewUserResponse]],
prisma_client: PrismaClient,
user_api_key_cache: DualCache,
user_defined_values: Optional[SSOUserDefinedValues],
) -> None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Misleading parameter name received_response

The parameter is named received_response but it actually receives the decoded JWT access token payload (or the stripped OAuth response as a fallback). The raw received_response from the OAuth exchange — what the name implies — is the object that intentionally lacks role claims (the bug being fixed). Using the same name for both the stripped OAuth response and the decoded JWT payload will confuse future maintainers.

Consider renaming this parameter to token_claims or jwt_payload to make clear it is the decoded JWT dictionary passed directly to map_jwt_role_to_litellm_role, and update the call site at line 2574 accordingly.

Comment on lines +1232 to +1237
await user_api_key_cache.async_set_cache(
key=user_info.user_id,
value=user_info.model_dump()
if hasattr(user_info, "model_dump")
else dict(user_info),
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cache update missing TTL

async_set_cache is called without a ttl argument. Depending on DualCache's default, this may write the updated user entry with no expiry (or a different default TTL than other user-cache entries set elsewhere), causing the stale-role scenario this PR fixes to persist until a manual cache flush.

Compare with how the cache is populated in get_user_info_from_db or similar helpers — those likely pass an explicit TTL. Passing the same TTL here keeps cache lifetime consistent across all writes to user_info.user_id.

# access token, which is stripped from received_response.
await _sync_user_role_from_jwt_role_map(
jwt_handler=jwt_handler,
received_response=access_token_payload or received_response,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fallback to stripped received_response contradicts fix intent

The PR description explains that received_response lacks custom role claims because bearer tokens were stripped (PR #22923). If access_token_payload is falsy (e.g. None for opaque tokens, or for Google/Microsoft SSO paths where it is never populated), this silently falls back to received_response — the very data source that is missing the claims.

In practice this is harmless today (for Google/MS SSO both will be None so the function exits early), but the guard should be explicit to avoid future confusion. Consider skipping the call entirely when access_token_payload is None rather than using the or fallback, so the two data sources are never silently interchanged.

@ryan-crabbe-berri ryan-crabbe-berri merged commit a533de0 into litellm_ryan-march-26 Mar 28, 2026
5 of 50 checks passed
@ryan-crabbe-berri ryan-crabbe-berri deleted the litellm_fix-jwt-role-mappings branch March 28, 2026 01:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants