feat(auth): add JWT claim routing overrides for OAuth2 validation - #24989
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR adds optional JWT claim-based routing overrides to LiteLLM's proxy auth layer, enabling JWT-shaped machine tokens to be dispatched to OAuth2 introspection rather than the standard JWT validation path. The implementation is well-isolated: a new Key changes:
Confidence Score: 4/5
|
| Filename | Overview |
|---|---|
| litellm/proxy/_types.py | Added JWTRoutingOverride Pydantic model (iss required, client_id/aud optional, path Literal["oauth2"]) and optional routing_overrides field on LiteLLM_JWTAuth; extra="forbid" prevents misconfiguration via unknown fields. |
| litellm/proxy/auth/handle_jwt.py | Moved jwt/PyJWK imports to module level, extracted SUPPORTED_JWT_ALGORITHMS to a class constant, and added get_unverified_claims() static method for signature-free JWT decoding used in routing decisions. |
| litellm/proxy/auth/user_api_key_auth.py | Added three pure helper functions for override matching and routing decision; modified auth-path dispatch to route JWT-shaped tokens to OAuth2 when a routing override matches using unverified claims. |
| tests/test_litellm/proxy/auth/test_user_api_key_auth.py | Three new mock-only tests added: full match routes to OAuth2, issuer-match/client_id-mismatch falls back to JWT, and list-valued selectors with list aud claim correctly match; no real network calls. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Incoming Bearer Token] --> B{enable_oauth2_auth?}
B -- No --> E{enable_jwt_auth?}
B -- Yes --> C{is_jwt?}
C -- No --> D[OAuth2 Introspection\nOauth2Handler.check_oauth2_token]
C -- Yes --> F{routing_overrides\nconfigured?}
F -- No --> G[route_jwt_to_oauth2 = False]
F -- Yes --> H[get_unverified_claims\nno signature verify]
H --> I{claims match\nany override rule?}
I -- No match --> G
I -- Matched iss/client_id/aud --> J[route_jwt_to_oauth2 = True]
J --> D
G --> E
E -- Yes --> K[auth_jwt\nfull signature verification]
K --> L[JWTAuthManager.auth_builder]
D --> M[Return UserAPIKeyAuth]
L --> M
Reviews (6): Last reviewed commit: "docs(auth): document JWT-to-OAuth2 routi..." | Re-trigger Greptile
| def _should_route_jwt_to_oauth2_override(token: str, jwt_handler: JWTHandler) -> bool: | ||
| routing_overrides = jwt_handler.litellm_jwtauth.routing_overrides | ||
| if not routing_overrides: | ||
| return False | ||
|
|
||
| token_claims = jwt_handler.get_unverified_claims(token=token) | ||
| if token_claims is None: | ||
| return False | ||
|
|
||
| for override in routing_overrides: | ||
| if override.path == "oauth2" and _matches_routing_override( | ||
| token_claims=token_claims, override=override | ||
| ): | ||
| verbose_proxy_logger.debug( | ||
| "JWT routing override matched. Routing token to OAuth2 introspection." | ||
| ) | ||
| return True | ||
|
|
||
| return False |
There was a problem hiding this comment.
Routing decision based on unverified JWT claims
The routing override check decodes the JWT payload without signature verification (get_unverified_claims uses verify_signature: False). Any caller can craft a JWT-shaped token (three base64url segments separated by dots) with arbitrary payload claims — for example, forging iss = "entlogin.ti.com" and client_id = "MID_LITELLM" — to match a routing override and force routing to the OAuth2 introspection path, bypassing the cryptographic JWT validation path entirely.
The security outcome then depends completely on the downstream OAuth2 introspection endpoint rejecting forged tokens. If the introspection endpoint is misconfigured, permissive, or unavailable (error handling at the call-site in Oauth2Handler.check_oauth2_token), this routing bypass could allow authentication with an invalid token.
Consider documenting explicitly that routing_overrides must only be used when the OAuth2 introspection endpoint performs its own full cryptographic validation, and ideally add an explicit warning log when a routing override is applied to a token whose claims have not been verified.
Rule Used: What: Fail any PR which may contains a security in... (source)
| @pytest.mark.asyncio | ||
| async def test_routing_override_routes_matching_jwt_to_oauth2(self): | ||
| """ | ||
| When routing_overrides match JWT claims, route JWT-shaped token to OAuth2. | ||
| """ | ||
| jwt_token = ( | ||
| "eyJhbGciOiJSUzI1NiJ9." | ||
| "eyJpc3MiOiJlbnRsb2dpbi50aS5jb20iLCJjbGllbnRfaWQiOiJNSURfTElURUxMTSJ9." | ||
| "c2ln" | ||
| ) | ||
| general_settings = { | ||
| "enable_oauth2_auth": True, | ||
| "enable_jwt_auth": True, | ||
| } | ||
| mock_oauth2_response = UserAPIKeyAuth( | ||
| api_key=jwt_token, | ||
| user_id="machine-client-override", | ||
| ) | ||
|
|
||
| mock_request = MagicMock() | ||
| mock_request.url.path = "/v1/chat/completions" | ||
| mock_request.headers = {"authorization": f"Bearer {jwt_token}"} | ||
| mock_request.query_params = {} | ||
|
|
||
| with patch( | ||
| "litellm.proxy.proxy_server.general_settings", general_settings | ||
| ), patch("litellm.proxy.proxy_server.premium_user", True), patch( | ||
| "litellm.proxy.proxy_server.master_key", "sk-master" | ||
| ), patch( | ||
| "litellm.proxy.proxy_server.prisma_client", None | ||
| ), patch( | ||
| "litellm.proxy.auth.user_api_key_auth.Oauth2Handler.check_oauth2_token", | ||
| new_callable=AsyncMock, | ||
| return_value=mock_oauth2_response, | ||
| ) as mock_oauth2, patch( | ||
| "litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder", | ||
| new_callable=AsyncMock, | ||
| ) as mock_jwt_auth: | ||
| litellm.proxy.proxy_server.jwt_handler.update_environment( | ||
| prisma_client=None, | ||
| user_api_key_cache=DualCache(), | ||
| litellm_jwtauth=LiteLLM_JWTAuth( | ||
| routing_overrides=[ | ||
| JWTRoutingOverride( | ||
| iss="entlogin.ti.com", | ||
| client_id="MID_LITELLM", | ||
| path="oauth2", | ||
| ) | ||
| ] | ||
| ), | ||
| ) | ||
|
|
||
| result = await user_api_key_auth( | ||
| request=mock_request, | ||
| api_key=f"Bearer {jwt_token}", | ||
| ) | ||
|
|
||
| mock_oauth2.assert_called_once_with(token=jwt_token) | ||
| mock_jwt_auth.assert_not_called() | ||
| assert result.user_id == "machine-client-override" |
There was a problem hiding this comment.
Missing test coverage for
aud claim and list-valued selectors
The two new tests cover iss-only matching and iss + client_id partial mismatch, but there are no tests exercising:
audclaim matching (the third selector field supported byJWTRoutingOverride)- List-valued
iss/client_id/audselectors (theUnion[str, List[str]]path in_routing_selector_matches_claim) - A JWT token whose
audclaim is itself a list (theisinstance(claim_value, list)branch)
These are real production paths in the helper functions — for example, OIDC tokens from Keycloak and Azure AD often carry list aud values. Adding at least one test covering aud list matching would catch regressions in the any(v in claim_list for v in selector_list) branch.
| for override in routing_overrides: | ||
| if override.path == "oauth2" and _matches_routing_override( | ||
| token_claims=token_claims, override=override | ||
| ): | ||
| verbose_proxy_logger.debug( | ||
| "JWT routing override matched. Routing token to OAuth2 introspection." | ||
| ) | ||
| return True |
There was a problem hiding this comment.
Redundant
path == "oauth2" guard
JWTRoutingOverride.path is typed as Literal["oauth2"], so the check override.path == "oauth2" is always True and adds no real filtering. This may give a false impression that multiple path values are dispatched here. Consider removing the guard and, if additional path values are planned in the future, add a comment explaining the forward-looking intent.
bdf4cdd to
79189e0
Compare
79189e0 to
c3af8b1
Compare
c3af8b1 to
db6b2f9
Compare
|
@greptileai - re-check again, for a p1 mentioned - we’re intentionally using unverified claims only for routing selection; final auth is enforced by the selected validator (JWT verify or OAuth2 introspection). We’ll keep logging informational and add a minimal in-code comment clarifying this assumption. |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
db6b2f9 to
5d6e769
Compare
8906d9e to
26861c8
Compare
Made-with: Cursor
Add generic docs for running JWT and OAuth2 together, including routing_overrides YAML examples and list-based selector behavior for iss/client_id/aud. Made-with: Cursor
26861c8 to
ae6cc70
Compare
46c2348
into
BerriAI:litellm_internal_dev_04_02_2026
Add optional JWT routing override rules under litellm_jwtauth to route matching JWT-shaped tokens to OAuth2 introspection while preserving existing fallback behavior when no rule matches.
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
tests/test_litellm/directory, Adding at least 1 test is a hard requirement - see detailsmake test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewDelays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
CI (LiteLLM team)
Branch creation CI run
Link:
CI run for the last commit
Link:
Merge / cherry-pick CI run
Links:
Type
🆕 New Feature
✅ Test
Changes
JWTRoutingOverridemodel and optionallitellm_jwtauth.routing_overridesconfig.routing_overridesrule andpath == "oauth2", token is routed to OAuth2 introspection.iss(required), optionalclient_id, optionalaud.JWTHandlerby addingget_unverified_claims()and consolidating supported JWT algorithm list.tests/test_litellm/proxy/auth/test_user_api_key_auth.py:poetry run pytest tests/test_litellm/proxy/auth -q->384 passed