From 0fada281538479292e6186637e16d2209196d0f9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 21 May 2026 17:42:18 +0000 Subject: [PATCH] fix(jwt): validate issuer audience at config load + dedicated key-miss exception - Move JWTIssuerConfig audience-required guard into a Pydantic model_validator so misconfiguration fails at startup instead of on the first request. - Replace the string-match `No matching public key found` filter in get_public_key's multi-URL fallback with a dedicated NoMatchingJWTPublicKeyError; only that specific exception triggers continuation, every other error still surfaces. --- litellm/proxy/_types.py | 8 +++++ litellm/proxy/auth/handle_jwt.py | 20 ++++------- tests/proxy_unit_tests/test_jwt.py | 28 +++++---------- .../proxy/auth/test_handle_jwt.py | 35 ++++++++----------- 4 files changed, 39 insertions(+), 52 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9fd2e76c772..19cd14622f8 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4470,6 +4470,14 @@ class JWTIssuerConfig(BaseModel): "extra": "forbid", } + @model_validator(mode="after") + def validate_audience_configured(self) -> "JWTIssuerConfig": + if self.audience is None and not self.disable_audience_validation: + raise ValueError( + f"JWT issuer {self.issuer} must configure audience or set disable_audience_validation=True" + ) + return self + class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase): """ diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index d0e23dd2c70..4b2bebbd036 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -67,6 +67,10 @@ from .auth_checks import ( ) +class NoMatchingJWTPublicKeyError(Exception): + """Raised when a JWKS endpoint returns no key matching the requested ``kid``.""" + + class JWTHandler: """ - treat the sub id passed in as the user id @@ -704,7 +708,7 @@ class JWTHandler: if public_key is not None: return cast(dict, public_key) - raise Exception( + raise NoMatchingJWTPublicKeyError( f"No matching public key found. keys={resolved_jwks_url}, kid={kid}" ) @@ -721,14 +725,12 @@ class JWTHandler: return await self._get_public_key_from_jwks_url( jwks_url=key_url, kid=kid ) - except Exception as e: - if "No matching public key found" not in str(e): - raise + except NoMatchingJWTPublicKeyError as e: verbose_proxy_logger.debug( "JWT Auth: No matching public key found at %s: %s", key_url, e ) - raise Exception( + raise NoMatchingJWTPublicKeyError( f"No matching public key found. keys={keys_url_list}, kid={kid}" ) @@ -1013,14 +1015,6 @@ class JWTHandler: async def _auth_jwt_with_issuer( self, token: str, issuer_config: JWTIssuerConfig, kid: Optional[str] ) -> dict: - if ( - issuer_config.audience is None - and not issuer_config.disable_audience_validation - ): - raise Exception( - f"JWT issuer {issuer_config.issuer} must configure audience or set disable_audience_validation=True" - ) - public_key = await self._get_public_key_from_jwks_url( jwks_url=self._get_jwks_url_for_issuer(issuer_config=issuer_config), kid=kid, diff --git a/tests/proxy_unit_tests/test_jwt.py b/tests/proxy_unit_tests/test_jwt.py index ca1bfdd93cb..92209e11315 100644 --- a/tests/proxy_unit_tests/test_jwt.py +++ b/tests/proxy_unit_tests/test_jwt.py @@ -1947,8 +1947,7 @@ async def test_multi_issuer_jwt_missing_mapped_claim_is_optional(monkeypatch): assert JWTHandler.LITELLM_USER_ID_CLAIM not in claims -@pytest.mark.asyncio -async def test_multi_issuer_jwt_requires_audience_unless_explicitly_disabled( +def test_multi_issuer_jwt_requires_audience_unless_explicitly_disabled( monkeypatch, ): monkeypatch.delenv("JWT_AUDIENCE", raising=False) @@ -1956,25 +1955,16 @@ async def test_multi_issuer_jwt_requires_audience_unless_explicitly_disabled( issuer = "https://issuer.example.com" jwks_url = f"{issuer}/keys" - private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") - jwt_handler = _get_jwt_handler_with_issuer_keys( - issuers=[ - { - "issuer": issuer, - "jwks_url": jwks_url, - } - ], - keys_by_url={jwks_url: [jwk]}, - ) - token = _encode_rsa_jwt( - private_key=private_key, - issuer=issuer, - audience="some-other-client", - kid="issuer-key", - ) with pytest.raises(Exception) as exc: - await jwt_handler.auth_jwt(token=token) + LiteLLM_JWTAuth( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + } + ] + ) assert "must configure audience" in str(exc.value) diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index ac392356cc7..0cae0b56f7f 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -2892,7 +2892,12 @@ async def test_get_public_key_tries_next_jwks_url_when_kid_missing(monkeypatch): def test_get_jwks_url_for_issuer_falls_back_to_discovery_document(): jwt_handler = JWTHandler() issuer_config = LiteLLM_JWTAuth( - issuers=[{"issuer": "https://issuer.example.com/tenant/"}] + issuers=[ + { + "issuer": "https://issuer.example.com/tenant/", + "disable_audience_validation": True, + } + ] ).issuers[0] jwks_url = jwt_handler._get_jwks_url_for_issuer(issuer_config=issuer_config) @@ -3146,8 +3151,7 @@ async def test_multi_issuer_jwt_missing_mapped_claim_leaves_user_id_unset( assert jwt_handler.LITELLM_USER_ID_CLAIM not in claims -@pytest.mark.asyncio -async def test_multi_issuer_jwt_requires_audience_unless_explicitly_disabled( +def test_multi_issuer_jwt_requires_audience_unless_explicitly_disabled( monkeypatch, ): monkeypatch.delenv("JWT_AUDIENCE", raising=False) @@ -3155,25 +3159,16 @@ async def test_multi_issuer_jwt_requires_audience_unless_explicitly_disabled( issuer = "https://issuer.example.com" jwks_url = f"{issuer}/keys" - private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") - jwt_handler = _get_jwt_handler_with_issuer_keys( - issuers=[ - { - "issuer": issuer, - "jwks_url": jwks_url, - } - ], - keys_by_url={jwks_url: [jwk]}, - ) - token = _encode_rsa_jwt( - private_key=private_key, - issuer=issuer, - audience="some-other-client", - kid="issuer-key", - ) with pytest.raises(Exception) as exc: - await jwt_handler.auth_jwt(token=token) + LiteLLM_JWTAuth( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + } + ] + ) assert "must configure audience" in str(exc.value)