From 00bd56760d8aba58185dacc1adb29f985fe6769b Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:51:27 +0000 Subject: [PATCH] fix(auth): enforce JWT end-user budget rate limits (#51405) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/user_api_key_auth.py | 8 +- .../test_user_api_key_auth_request_flow.py | 73 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index ff6e7f84bed..02d0e8736e3 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -4008,7 +4008,11 @@ async def run_centralized_common_checks( user_api_key_auth_obj.project_metadata = project_object.metadata user_api_key_auth_obj.project_alias = project_object.project_alias - if end_user_id and key_end_user_budget_id is not None and prisma_client is not None: + if ( + end_user_id + and (key_end_user_budget_id is not None or user_api_key_auth_obj.jwt_claims is not None) + and prisma_client is not None + ): await _apply_key_end_user_default_budget_to_token( valid_token=user_api_key_auth_obj, end_user_object=end_user_object, @@ -4196,7 +4200,7 @@ async def _noop_none() -> None: async def _apply_key_end_user_default_budget_to_token( valid_token: UserAPIKeyAuth, end_user_object: LiteLLM_EndUserTable | None, - key_end_user_budget_id: str, + key_end_user_budget_id: str | None, prisma_client: PrismaClient, user_api_key_cache: UserApiKeyCache, parent_otel_span: Span | None, diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py index 67a76906dc8..af7ee104351 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py @@ -19,8 +19,10 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch import fastapi +import jwt import orjson import pytest +from cryptography.hazmat.primitives.asymmetric import rsa from starlette.routing import Route from litellm.redis.stores.connection import connect @@ -39,6 +41,7 @@ from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS from litellm.proxy.common_utils.user_api_key_cache import ( NO_TEAM_MEMBERSHIP_SENTINEL, UserApiKeyCache, + end_user_cache_key, team_membership_reservation_cache_key, ) from litellm.proxy.hooks.model_max_budget_limiter import ( @@ -1998,6 +2001,76 @@ def test_proxy_admin_jwt_auth_handles_no_team_object(): assert result.end_user_id is None +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["claim", "body"]) +@pytest.mark.parametrize("limits", [(1, 7), (0, 0), (None, None)]) +async def test_jwt_customer_budget_rate_limits_reach_enforcement( + monkeypatch: pytest.MonkeyPatch, + source: str, + limits: tuple[int | None, int | None], +) -> None: + cache: Final = UserApiKeyCache() + database: Final = MagicMock() + handler: Final = JWTHandler() + handler.update_environment( + prisma_client=database, + user_api_key_cache=cache, + litellm_jwtauth=LiteLLM_JWTAuth( + team_id_jwt_field="team_id", + end_user_id_jwt_field="customer_id" if source == "claim" else None, + ), + ) + private_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048) + jwks_url: Final = "https://identity.invalid/rate-limits/jwks" + cache.set_cache( + f"litellm_jwt_auth_keys_{jwks_url}", + [json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(private_key.public_key()))], + ) + cache.set_cache( + "team_id:rate-limit-team", + LiteLLM_TeamTable(team_id="rate-limit-team", models=[]), + model_type=LiteLLM_TeamTable, + ) + cache.set_cache( + end_user_cache_key("rate-limit-customer"), + LiteLLM_EndUserTable( + user_id="rate-limit-customer", + blocked=False, + litellm_budget_table=LiteLLM_BudgetTable(rpm_limit=limits[0], tpm_limit=limits[1]), + ), + model_type=LiteLLM_EndUserTable, + ) + monkeypatch.setenv("JWT_PUBLIC_KEY_URL", jwks_url) + monkeypatch.delenv("JWT_ISSUER", raising=False) + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.setattr(litellm.proxy.proxy_server, "jwt_handler", handler) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", database) + monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", cache) + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "master_key", "rate-limit-master") + monkeypatch.setattr(litellm.proxy.proxy_server, "user_custom_auth", None) + monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", None) + monkeypatch.setattr( + litellm.proxy.proxy_server, "general_settings", {"enable_jwt_auth": True, "disable_budget_reservation": True} + ) + token: Final = jwt.encode( + {"team_id": "rate-limit-team", **({"customer_id": "rate-limit-customer"} if source == "claim" else {})}, + private_key, + algorithm="RS256", + ) + request: Final = Request( + {"type": "http", "method": "POST", "path": "/chat/completions", "headers": [], "query_string": b""} + ) + request._body = json.dumps( + {"model": "rate-limit-model", **({"user": "rate-limit-customer"} if source == "body" else {})} + ).encode() + try: + auth: Final = await user_api_key_auth(request=request, api_key=f"Bearer {token}") + assert (auth.end_user_id, auth.end_user_rpm_limit, auth.end_user_tpm_limit) == ("rate-limit-customer", *limits) + finally: + await handler.close() + + @pytest.mark.asyncio async def test_standard_jwt_auth_propagates_user_email(): """