fix(proxy): populate user_email on UserAPIKeyAuth for JWT auth (#34174)
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run

JWT auth built UserAPIKeyAuth without user_email even though the resolved
user row and the JWT email claim were both available, so the user_email
label on Prometheus metrics and user_api_key_user_email in
StandardLogging/SpendLogs metadata were always None for JWT traffic.

Plumb user_email through JWTAuthBuilderResult: auth_builder returns the
user row email when set, falling back to the user_email_jwt_field claim
(covers the scope-based proxy-admin path where no user row is loaded).
The JWT branch now stamps it on the proxy-admin return, the standard
valid_token, and the auto-registered virtual key object.

Resolves LIT-4238
This commit is contained in:
ryan-crabbe-berri 2026-07-21 16:11:47 -07:00 • committed by GitHub
parent 02746eb122
commit e17f3b6e1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 220 additions and 1 deletions

View file

@ -4052,6 +4052,7 @@ class JWTAuthBuilderResult(TypedDict):
token: str
team_id: Optional[str]
user_id: Optional[str]
user_email: str | None
end_user_id: Optional[str]
org_id: Optional[str]
team_membership: Optional[LiteLLM_TeamMembership]

View file

@ -1155,6 +1155,7 @@ class JWTAuthManager:
org_id: Optional[str],
api_key: str,
jwt_valid_token: Optional[dict] = None,
user_email: str | None = None,
) -> Optional[JWTAuthBuilderResult]:
"""Check admin status and route access permissions"""
if not jwt_handler.is_admin(scopes=scopes):
@ -1179,6 +1180,7 @@ class JWTAuthManager:
token=api_key,
team_id=None,
user_id=user_id,
user_email=user_email,
end_user_id=None,
org_id=org_id,
team_membership=None,
@ -2068,7 +2070,7 @@ class JWTAuthManager:
# Check admin access
admin_result = await JWTAuthManager.check_admin_access(
jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token
jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token, user_email=user_email
)
if admin_result:
await JWTAuthManager._attach_team_from_header_for_admin(
@ -2303,6 +2305,7 @@ class JWTAuthManager:
team_id=team_id,
team_object=team_object,
user_id=user_id,
user_email=(user_object.user_email if user_object is not None and user_object.user_email else user_email),
user_object=user_object,
org_id=resolved_org_id, # Use resolved org_id (from alias lookup if applicable)
org_object=org_object,

View file

@ -1255,6 +1255,7 @@ async def _user_api_key_auth_builder(
team_id = result["team_id"]
team_object = result["team_object"]
user_id = result["user_id"]
user_email = result["user_email"]
user_object = result["user_object"]
end_user_id = result["end_user_id"]
org_id = result["org_id"]
@ -1279,6 +1280,7 @@ async def _user_api_key_auth_builder(
api_key=None,
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id=user_id,
user_email=user_email,
team_id=team_id,
team_alias=(team_object.team_alias if team_object is not None else None),
team_tpm_limit=(team_object.tpm_limit if team_object is not None else None),
@ -1304,6 +1306,7 @@ async def _user_api_key_auth_builder(
else LitellmUserRoles.INTERNAL_USER
),
user_id=user_id,
user_email=user_email,
org_id=org_id,
parent_otel_span=parent_otel_span,
end_user_id=end_user_id,
@ -1345,6 +1348,7 @@ async def _user_api_key_auth_builder(
)
if auto_registered is not None:
auto_registered.jwt_claims = jwt_claims
auto_registered.user_email = user_email
valid_token = auto_registered
api_key = valid_token.token or ""

View file

@ -1115,6 +1115,7 @@ async def test_jwt_non_admin_team_route_access(monkeypatch):
"team_id": None,
"team_object": None,
"user_id": None,
"user_email": None,
"user_object": None,
"org_id": None,
"org_object": None,

View file

@ -458,6 +458,118 @@ async def test_auth_builder_non_proxy_admin_user_role():
assert result["user_id"] == "test_user_1"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"row_email,expected_email",
[
("row@example.com", "row@example.com"),
(None, "claim@example.com"),
("", "claim@example.com"),
],
)
async def test_auth_builder_result_includes_user_email(row_email, expected_email):
"""LIT-4238: auth_builder must return user_email (user row wins, JWT claim
is the fallback) so the auth object and metrics get the email."""
api_key = "test_jwt_token"
request_data = {"model": "gpt-4"}
general_settings = {"enforce_rbac": False}
route = "/chat/completions"
user_object = LiteLLM_UserTable(
user_id="test_user_1",
user_email=row_email,
user_role=LitellmUserRoles.INTERNAL_USER,
)
jwt_handler = JWTHandler()
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth()
with (
patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth_jwt,
patch.object(JWTAuthManager, "check_rbac_role", new_callable=AsyncMock),
patch.object(jwt_handler, "get_rbac_role", return_value=None),
patch.object(jwt_handler, "get_scopes", return_value=[]),
patch.object(jwt_handler, "get_object_id", return_value=None),
patch.object(
JWTAuthManager,
"get_user_info",
new_callable=AsyncMock,
return_value=("test_user_1", "claim@example.com", True),
),
patch.object(jwt_handler, "get_org_id", return_value=None),
patch.object(jwt_handler, "get_end_user_id", return_value=None),
patch.object(
JWTAuthManager,
"check_admin_access",
new_callable=AsyncMock,
return_value=None,
) as mock_check_admin,
patch.object(
JWTAuthManager,
"find_and_validate_specific_team_id",
new_callable=AsyncMock,
return_value=(None, None),
),
patch.object(JWTAuthManager, "get_all_team_ids", return_value=set()),
patch.object(
JWTAuthManager,
"find_team_with_model_access",
new_callable=AsyncMock,
return_value=(None, None),
),
patch.object(
JWTAuthManager,
"get_objects",
new_callable=AsyncMock,
return_value=(user_object, None, None, None, user_object.user_id),
),
patch.object(JWTAuthManager, "map_user_to_teams", new_callable=AsyncMock),
patch.object(JWTAuthManager, "validate_object_id", return_value=True),
):
mock_auth_jwt.return_value = {"sub": "test_user_1", "scope": ""}
result = await JWTAuthManager.auth_builder(
api_key=api_key,
jwt_handler=jwt_handler,
request_data=request_data,
general_settings=general_settings,
route=route,
prisma_client=None,
user_api_key_cache=None,
parent_otel_span=None,
proxy_logging_obj=None,
)
assert result["user_email"] == expected_email
assert mock_check_admin.call_args.kwargs["user_email"] == "claim@example.com"
@pytest.mark.asyncio
async def test_check_admin_access_result_includes_user_email():
"""LIT-4238: the scope-based admin path has no user row, so the JWT claim
email must ride the JWTAuthBuilderResult."""
jwt_handler = JWTHandler()
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(
admin_jwt_scope="litellm_proxy_admin",
admin_allowed_routes=["/chat/completions"],
)
result = await JWTAuthManager.check_admin_access(
jwt_handler=jwt_handler,
scopes=["litellm_proxy_admin"],
route="/chat/completions",
user_id="admin-user",
user_email="admin@example.com",
org_id=None,
api_key="test_jwt_token",
jwt_valid_token={"sub": "admin-user"},
)
assert result is not None
assert result["is_proxy_admin"] is True
assert result["user_email"] == "admin@example.com"
@pytest.mark.asyncio
async def test_sync_user_role_and_teams():
from unittest.mock import MagicMock

View file

@ -1569,6 +1569,7 @@ class TestJWTOAuth2Coexistence:
"token": jwt_token,
"team_id": "jwt-team",
"user_id": "jwt-human-user",
"user_email": None,
"end_user_id": None,
"org_id": None,
"team_membership": None,
@ -1643,6 +1644,7 @@ class TestJWTOAuth2Coexistence:
"token": jwt_token,
"team_id": "validated-team",
"user_id": "validated-user",
"user_email": "validated@example.com",
"end_user_id": "validated-end-user",
"org_id": "validated-org",
"team_membership": None,
@ -1702,6 +1704,7 @@ class TestJWTOAuth2Coexistence:
mock_auto_register.call_args.kwargs["end_user_id"] == "validated-end-user"
)
assert result.org_id == "validated-org"
assert result.user_email == "validated@example.com"
@pytest.mark.asyncio
async def test_routing_override_routes_matching_jwt_to_oauth2(self):
@ -1788,6 +1791,7 @@ class TestJWTOAuth2Coexistence:
"token": jwt_token,
"team_id": "jwt-team",
"user_id": "jwt-user-no-override",
"user_email": None,
"end_user_id": None,
"org_id": None,
"team_membership": None,
@ -1988,6 +1992,7 @@ class TestJWTOAuth2Coexistence:
"token": jwt_token,
"team_id": "jwt-team",
"user_id": "jwt-user-scope-mismatch",
"user_email": None,
"end_user_id": None,
"org_id": None,
"team_membership": None,
@ -2296,6 +2301,7 @@ class TestJWTOAuth2Coexistence:
"token": jwt_token,
"team_id": None,
"user_id": "jwt-admin-user",
"user_email": None,
"end_user_id": None,
"org_id": None,
"team_membership": None,
@ -4255,6 +4261,98 @@ async def test_auth_does_not_rewrite_cached_key_object_back_into_cache():
setattr(_proxy_server_mod, k, v)
class TestJWTAuthUserEmail:
"""JWT auth must populate `UserAPIKeyAuth.user_email` (LIT-4238); it feeds
the Prometheus `user_email` label and `user_api_key_user_email` in
StandardLogging/SpendLogs metadata, which were always None for JWT traffic."""
def _jwt_request(self, jwt_token):
mock_request = MagicMock()
mock_request.url.path = "/v1/chat/completions"
mock_request.method = "POST"
mock_request.headers = {"authorization": f"Bearer {jwt_token}"}
mock_request.query_params = {}
return mock_request
async def _run_jwt_auth(self, mock_jwt_result, jwt_token):
with (
patch(
"litellm.proxy.proxy_server.general_settings",
{"enable_jwt_auth": True},
),
patch("litellm.proxy.proxy_server.premium_user", True),
patch("litellm.proxy.proxy_server.master_key", "sk-master"),
patch("litellm.proxy.proxy_server.prisma_client", None),
patch(
"litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder",
new_callable=AsyncMock,
return_value=mock_jwt_result,
),
):
litellm.proxy.proxy_server.jwt_handler.update_environment(
prisma_client=None,
user_api_key_cache=DualCache(),
litellm_jwtauth=LiteLLM_JWTAuth(),
)
return await user_api_key_auth(
request=self._jwt_request(jwt_token),
api_key=f"Bearer {jwt_token}",
)
@pytest.mark.asyncio
async def test_jwt_auth_populates_user_email_on_valid_token(self):
jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
mock_jwt_result = {
"is_proxy_admin": False,
"team_object": None,
"user_object": LiteLLM_UserTable(
user_id="jwt-human-user",
user_email="row@example.com",
user_role=LitellmUserRoles.INTERNAL_USER.value,
),
"end_user_object": None,
"org_object": None,
"token": jwt_token,
"team_id": None,
"user_id": "jwt-human-user",
"user_email": "resolved@example.com",
"end_user_id": None,
"org_id": None,
"team_membership": None,
"jwt_claims": {"sub": "user1"},
}
result = await self._run_jwt_auth(mock_jwt_result, jwt_token)
assert result.user_id == "jwt-human-user"
assert result.user_email == "resolved@example.com"
@pytest.mark.asyncio
async def test_jwt_auth_populates_user_email_on_proxy_admin(self):
jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
mock_jwt_result = {
"is_proxy_admin": True,
"team_object": None,
"user_object": None,
"end_user_object": None,
"org_object": None,
"token": jwt_token,
"team_id": None,
"user_id": "jwt-admin-user",
"user_email": "admin@example.com",
"end_user_id": None,
"org_id": None,
"team_membership": None,
"jwt_claims": {"sub": "user1"},
}
result = await self._run_jwt_auth(mock_jwt_result, jwt_token)
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
assert result.user_id == "jwt-admin-user"
assert result.user_email == "admin@example.com"
class TestCheckKeyModelBudgetWithFallback:
"""`_check_key_model_budget_with_fallback` must reroute a request to the
first configured `budget_fallbacks` entry still within its own budget,