diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index d0d084a0582..4d05447fa90 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -677,6 +677,12 @@ async def check_api_key_for_custom_headers_or_pass_through_endpoints( # the lookup and return None (caller proceeds to auth_builder). _JWT_PROXY_ADMIN_SENTINEL: Final = "__JWT_PROXY_ADMIN__" +_JWT_AUTH_DISABLED_HINT = ( + " This key has the structure of a JWT, but JWT auth is not enabled on this proxy, so it was treated as a" + " virtual key. Set `enable_jwt_auth: true` under `general_settings` in your proxy config to authenticate" + " with JWTs." +) + class _PendingAutoRegister(NamedTuple): """ @@ -1206,8 +1212,11 @@ async def _user_api_key_auth_builder( from litellm.proxy.proxy_server import premium_user if premium_user is not True: - raise ValueError( - f"JWT Auth is an enterprise only feature. {CommonProxyErrors.not_premium_user.value}" + raise ProxyException( + message=f"JWT Auth is an enterprise only feature. {CommonProxyErrors.not_premium_user.value}", + type=ProxyErrorTypes.auth_error, + param="premium_user", + code=status.HTTP_403_FORBIDDEN, ) # Try JWT-to-Virtual-Key mapping first to avoid # unnecessary DB queries in auth_builder @@ -1672,9 +1681,13 @@ async def _user_api_key_auth_builder( if isinstance(api_key, str): # if generated token, make sure it starts with sk-. _masked_key: Final = f"{api_key[:4]}****{api_key[-4:]}" if len(api_key) > 8 else "****" if not api_key.startswith("sk-"): + _hint = _JWT_AUTH_DISABLED_HINT if not enable_jwt_auth and JWTHandler.is_jwt(token=api_key) else "" raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail=(f"LiteLLM Virtual Key expected. Received={_masked_key}, expected to start with 'sk-'."), + detail=( + f"LiteLLM Virtual Key expected. Received={_masked_key}, " + f"expected to start with 'sk-'.{_hint}" + ), ) # prevent token hashes from being used else: verbose_logger.warning( diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 7c355b3b925..36e9ae5f992 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -5681,3 +5681,101 @@ async def test_temp_budget_increase_applied_for_cached_key(): cached_after = await user_api_key_cache.async_get_cache(key=hashed_token) assert cached_after.max_budget == 2.0 + + +async def _proxy_exception_for_key( + api_key: str, + general_settings: dict[str, bool], + premium_user: bool, +) -> ProxyException: + mock_request = MagicMock() + mock_request.url.path = "/v1/chat/completions" + mock_request.method = "POST" + mock_request.headers = {"authorization": f"Bearer {api_key}"} + mock_request.query_params = {} + mock_request.state = SimpleNamespace() + + proxy_logging_obj = MagicMock() + proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None) + + user_api_key_cache = DualCache() + jwt_handler = JWTHandler() + jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=user_api_key_cache, + litellm_jwtauth=LiteLLM_JWTAuth(), + ) + + with ( + patch("litellm.proxy.proxy_server.general_settings", general_settings), + patch("litellm.proxy.proxy_server.premium_user", premium_user), + patch("litellm.proxy.proxy_server.master_key", "sk-master"), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.user_api_key_cache", user_api_key_cache), + patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj), + patch("litellm.proxy.proxy_server.jwt_handler", jwt_handler), + ): + with pytest.raises(ProxyException) as exc_info: + await _user_api_key_auth_builder( + request=mock_request, + api_key=f"Bearer {api_key}", + azure_api_key_header="", + anthropic_api_key_header=None, + google_ai_studio_api_key_header=None, + azure_apim_header=None, + request_data={"model": "gpt-4o-mini"}, + ) + + return exc_info.value + + +@pytest.mark.asyncio +async def test_jwt_shaped_key_error_names_enable_jwt_auth_when_disabled(): + """ + A three-segment token presented while `general_settings.enable_jwt_auth` + is unset is never treated as JWT-shaped, so it falls through to the + virtual-key path and is rejected for not starting with 'sk-'. That reads + as a missing database row and sends the operator to inspect virtual keys, + when the real cause is the missing config key. The rejection must name + `enable_jwt_auth`, and must claim only that the key is JWT-shaped, since + segment count cannot tell a JWT from any other dotted credential. + + The existing 'expected to start with sk-' text has to survive: the + Prometheus invalid-key filter and the admin UI both substring-match it. + Keys that are not JWT-shaped must not pick up the hint. + """ + jwt_error = await _proxy_exception_for_key( + "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzdmMtMSJ9.c2lnbmF0dXJl", {}, True + ) + + assert jwt_error.code == "401" + assert "enable_jwt_auth" in jwt_error.message + assert "general_settings" in jwt_error.message + assert "expected to start with 'sk-'" in jwt_error.message + assert "structure of a JWT" in jwt_error.message + assert "is a JWT" not in jwt_error.message + + opaque_error = await _proxy_exception_for_key("not-a-jwt-at-all", {}, True) + two_segment_error = await _proxy_exception_for_key( + "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzdmMtMSJ9", {}, True + ) + + assert "enable_jwt_auth" not in opaque_error.message + assert "enable_jwt_auth" not in two_segment_error.message + + +@pytest.mark.asyncio +async def test_unlicensed_jwt_auth_is_forbidden_not_unauthorized(): + """ + JWT auth is enterprise-gated. An unlicensed install must answer 403 like + every other enterprise gate; a 401 tells the client its credential was + wrong and invites a retry loop that can never succeed. + """ + error = await _proxy_exception_for_key( + "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzdmMtMSJ9.c2lnbmF0dXJl", + {"enable_jwt_auth": True}, + False, + ) + + assert error.code == "403" + assert "enterprise" in error.message.lower()