Skip to content

feat(auth): add JWT claim routing overrides for OAuth2 validation - #24989

Merged
yuneng-berri merged 2 commits into
BerriAI:litellm_internal_dev_04_02_2026from
milan-berri:feat/jwt-routing-overrides
Apr 2, 2026
Merged

feat(auth): add JWT claim routing overrides for OAuth2 validation#24989
yuneng-berri merged 2 commits into
BerriAI:litellm_internal_dev_04_02_2026from
milan-berri:feat/jwt-routing-overrides

Conversation

@milan-berri

@milan-berri milan-berri commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

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

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays 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)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Type

🆕 New Feature
✅ Test

Changes

  • Added JWTRoutingOverride model and optional litellm_jwtauth.routing_overrides config.
  • Added claim-based routing override for JWT-shaped tokens in auth flow:
    • If token claims match a routing_overrides rule and path == "oauth2", token is routed to OAuth2 introspection.
    • If no rule matches, default behavior is unchanged.
  • Rule matching supports iss (required), optional client_id, optional aud.
  • Reused JWT decode utility in JWTHandler by adding get_unverified_claims() and consolidating supported JWT algorithm list.
  • Added tests in tests/test_litellm/proxy/auth/test_user_api_key_auth.py:
    • Matching override routes JWT token to OAuth2.
    • Issuer match + client mismatch falls back to JWT flow.
  • Verified test runs:
    • poetry run pytest tests/test_litellm/proxy/auth -q -> 384 passed
    • Focused JWT/Auth tests passed after import ordering cleanups.

@vercel

vercel Bot commented Apr 2, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Apr 2, 2026 6:38pm

Request Review

@codspeed-hq

codspeed-hq Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing milan-berri:feat/jwt-routing-overrides (ae6cc70) with main (d1df4e8)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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 JWTRoutingOverride Pydantic model with extra="forbid" guards misconfiguration, three clean helper functions (_routing_selector_matches_claim, _matches_routing_override, _should_route_jwt_to_oauth2_override) handle the routing logic, and the only change to the critical _user_api_key_auth_builder path is the addition of a single route_jwt_to_oauth2 boolean that feeds into the existing dispatch switch. The refactoring in handle_jwt.py (module-level imports, extracted SUPPORTED_JWT_ALGORITHMS constant, new get_unverified_claims static method) cleanly consolidates shared JWT machinery.

Key changes:

  • litellm/proxy/_types.py: New JWTRoutingOverride model (iss required, client_id/aud optional, path: Literal["oauth2"]) added to LiteLLM_JWTAuth.routing_overrides.
  • litellm/proxy/auth/handle_jwt.py: SUPPORTED_JWT_ALGORITHMS promoted to a class constant; jwt/PyJWK imports moved to module level; new get_unverified_claims() static method decodes JWT payload without signature verification for routing use only.
  • litellm/proxy/auth/user_api_key_auth.py: Routing override helpers added; is_jwt_token renamed to is_jwt; dispatch condition updated to if not is_jwt or route_jwt_to_oauth2.
  • Tests: Three mock-only tests cover full match → OAuth2, iss-match/client_id-mismatch → JWT fallback, and list-valued selectors with list aud claim — satisfying the previously requested aud/list coverage.
  • Docs: Both oauth2.md and token_auth.md updated with configuration examples and matching semantics.

Confidence Score: 4/5

  • Safe to merge once the previously flagged unverified-claim routing security trade-off is explicitly accepted by a maintainer; no new P0/P1 issues introduced by this revision.
  • All three previously noted concerns have been addressed: the aud/list-selector test coverage was added, the security acknowledgment comment was added in-code, and the implementation itself is logically correct with proper fallback behavior. The score stays at 4 rather than 5 because the architectural choice of routing based on unverified JWT claims (per custom rule b4c07ced) is a genuine security trade-off that a maintainer should explicitly sign off on before merge — the correctness of this approach is entirely dependent on the downstream OAuth2 introspection endpoint being correctly configured to reject forged tokens.
  • litellm/proxy/auth/user_api_key_auth.py — the _should_route_jwt_to_oauth2_override routing path deserves maintainer eyes given the security surface it introduces.

Important Files Changed

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
Loading

Reviews (6): Last reviewed commit: "docs(auth): document JWT-to-OAuth2 routi..." | Re-trigger Greptile

Comment on lines +173 to +191
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

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.

P1 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)

Comment on lines +692 to +751
@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"

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

  • aud claim matching (the third selector field supported by JWTRoutingOverride)
  • List-valued iss / client_id / aud selectors (the Union[str, List[str]] path in _routing_selector_matches_claim)
  • A JWT token whose aud claim is itself a list (the isinstance(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.

Comment on lines +182 to +189
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

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 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.

@milan-berri

Copy link
Copy Markdown
Contributor Author

@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

codecov Bot commented Apr 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.27273% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/auth/handle_jwt.py 66.66% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

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
@milan-berri
milan-berri force-pushed the feat/jwt-routing-overrides branch from 26861c8 to ae6cc70 Compare April 2, 2026 18:36
@yuneng-berri
yuneng-berri changed the base branch from main to litellm_internal_dev_04_02_2026 April 2, 2026 18:39
@yuneng-berri
yuneng-berri merged commit 46c2348 into BerriAI:litellm_internal_dev_04_02_2026 Apr 2, 2026
51 of 59 checks passed
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