fix(auth): enforce JWT end-user budget rate limits (#51405)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Mateo Wang 2026-11-12 16:51:27 +00:00 • committed by GitHub
parent 40981a5d89
commit 00bd56760d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 79 additions and 2 deletions

View file

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

View file

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