mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-12 21:04:10 +00:00
fix(auth): preserve 401 status for expired JWTs in OTel traces (#29510)
* fix(auth): preserve 401 status for expired JWTs in OTel traces Expired JWT access tokens raised a generic Exception with no status code attached. Because the codeless exception was logged to OTel via post_call_failure_hook before auth_exception_handler re-wrapped it as ProxyException(401), the OTel span never set http.response.status_code and trace viewers displayed it as a generic 500. Clients still got a 401 back, so traces and actual responses diverged. Raise ProxyException(code=401, type=expired_key) directly at the source in both JWT decode paths so the 401 is consistent across the client response and the OTel http.response.status_code attribute, matching how virtual-key expirations are handled. * fix(auth): preserve 401 for expired JWTs on issuer-scoped path The issuer-scoped JWT path (_auth_jwt_with_issuer) still raised a generic Exception on expiry, surfacing as a 500 in client responses and OTel traces. Raise ProxyException with expired_key/401 there too, matching auth_jwt, and add a regression test exercising the issuer path end-to-end
This commit is contained in:
committed by
GitHub
parent
3a1c6bba97
commit
9d9558e78f
@@ -17,7 +17,7 @@ from typing import Any, List, Literal, Optional, Set, Tuple, Union, cast
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, status
|
||||
import jwt
|
||||
from jwt.api_jwk import PyJWK
|
||||
|
||||
@@ -1059,7 +1059,12 @@ class JWTHandler:
|
||||
disable_audience_validation=issuer_config.disable_audience_validation,
|
||||
)
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise Exception("Token Expired")
|
||||
raise ProxyException(
|
||||
message="Token Expired",
|
||||
type=ProxyErrorTypes.expired_key,
|
||||
param=None,
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(f"Validation fails: {str(e)}")
|
||||
|
||||
@@ -1103,8 +1108,12 @@ class JWTHandler:
|
||||
}
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
# the token is expired, do something to refresh it
|
||||
raise Exception("Token Expired")
|
||||
raise ProxyException(
|
||||
message="Token Expired",
|
||||
type=ProxyErrorTypes.expired_key,
|
||||
param=None,
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(f"Validation fails: {str(e)}")
|
||||
|
||||
|
||||
@@ -3181,6 +3181,84 @@ def test_build_decode_kwargs_no_warning_when_scoped(
|
||||
assert matching == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_jwt_expired_token_raises_401_jwk_path():
|
||||
"""An expired JWT (access token) decoded via the JWK/dict public-key path
|
||||
must raise a ProxyException carrying a 401 status code so the status is
|
||||
preserved end-to-end (client response + OTel traces).
|
||||
"""
|
||||
import jwt as jwt_lib
|
||||
|
||||
jwt_handler = JWTHandler()
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
jwt_handler, "get_public_key", new_callable=AsyncMock
|
||||
) as mock_get_public_key,
|
||||
patch(
|
||||
"litellm.proxy.auth.handle_jwt.jwt.get_unverified_header",
|
||||
return_value={"kid": "test-kid"},
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.handle_jwt.PyJWK.from_dict",
|
||||
return_value=MagicMock(key="fake-key"),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.handle_jwt.jwt.decode",
|
||||
side_effect=jwt_lib.ExpiredSignatureError("Signature has expired"),
|
||||
),
|
||||
):
|
||||
mock_get_public_key.return_value = {"kty": "RSA", "kid": "test-kid"}
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await jwt_handler.auth_jwt(token="expired.jwt.token")
|
||||
|
||||
assert exc_info.value.code == str(401)
|
||||
assert exc_info.value.type == ProxyErrorTypes.expired_key.value
|
||||
assert "Token Expired" in exc_info.value.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_jwt_expired_token_raises_401_pem_cert_path():
|
||||
"""Same as above but for the PEM-certificate (string public-key) decode path."""
|
||||
import jwt as jwt_lib
|
||||
|
||||
jwt_handler = JWTHandler()
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth()
|
||||
|
||||
mock_cert = MagicMock()
|
||||
mock_cert.public_key.return_value.public_bytes.return_value = b"fake-key"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
jwt_handler, "get_public_key", new_callable=AsyncMock
|
||||
) as mock_get_public_key,
|
||||
patch(
|
||||
"litellm.proxy.auth.handle_jwt.jwt.get_unverified_header",
|
||||
return_value={"kid": "test-kid"},
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.handle_jwt.x509.load_pem_x509_certificate",
|
||||
return_value=mock_cert,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.handle_jwt.jwt.decode",
|
||||
side_effect=jwt_lib.ExpiredSignatureError("Signature has expired"),
|
||||
),
|
||||
):
|
||||
mock_get_public_key.return_value = (
|
||||
"-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----"
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await jwt_handler.auth_jwt(token="expired.jwt.token")
|
||||
|
||||
assert exc_info.value.code == str(401)
|
||||
assert exc_info.value.type == ProxyErrorTypes.expired_key.value
|
||||
assert "Token Expired" in exc_info.value.message
|
||||
|
||||
|
||||
def _base64url_encode_int(value: int) -> str:
|
||||
import base64
|
||||
|
||||
@@ -3391,6 +3469,44 @@ async def test_multi_issuer_jwt_validates_selected_issuer_and_maps_claims(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_jwt_issuer_path_expired_token_raises_401(monkeypatch):
|
||||
"""An expired JWT validated through the issuer-scoped path
|
||||
(_auth_jwt_with_issuer) must raise a ProxyException carrying a 401 so the
|
||||
status is preserved end-to-end, just like the non-issuer path.
|
||||
"""
|
||||
import time
|
||||
|
||||
monkeypatch.delenv("JWT_AUDIENCE", raising=False)
|
||||
monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False)
|
||||
|
||||
issuer = "https://issuer.example.com"
|
||||
jwks_url = f"{issuer}/keys"
|
||||
kid = "expired-kid"
|
||||
|
||||
private_key, jwk = _get_rsa_key_and_jwk(kid=kid)
|
||||
|
||||
jwt_handler = _get_jwt_handler_with_issuer_keys(
|
||||
issuers=[{"issuer": issuer, "jwks_url": jwks_url, "audience": "my-audience"}],
|
||||
keys_by_url={jwks_url: [jwk]},
|
||||
)
|
||||
|
||||
token = _encode_rsa_jwt(
|
||||
private_key=private_key,
|
||||
issuer=issuer,
|
||||
audience="my-audience",
|
||||
kid=kid,
|
||||
extra_claims={"exp": int(time.time()) - 100},
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await jwt_handler.auth_jwt(token=token)
|
||||
|
||||
assert exc_info.value.code == str(401)
|
||||
assert exc_info.value.type == ProxyErrorTypes.expired_key.value
|
||||
assert "Token Expired" in exc_info.value.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_issuer_jwt_maps_kubernetes_namespace_claim(monkeypatch):
|
||||
monkeypatch.delenv("JWT_AUDIENCE", raising=False)
|
||||
|
||||
Reference in New Issue
Block a user