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.
This commit is contained in:
mateo-berri 2026-05-21 17:42:18 +00:00
parent 123f0fd482
commit 0fada28153
No known key found for this signature in database
4 changed files with 39 additions and 52 deletions

View file

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

View file

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

View file

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

View file

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