mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(auth): name enable_jwt_auth when a JWT-shaped key is rejected (#35831)
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 key in the verification table and sends the operator off to inspect virtual keys, when the real cause is one missing config line. The rejection now names `enable_jwt_auth`, appended to the existing text so the Prometheus invalid-key filter and the admin UI keep matching what they match today. The hint claims only that the key is JWT-shaped. Segment count cannot tell a JWT from any other dotted credential, so asserting the key IS a JWT would swap one confident misdiagnosis for a narrower one. The enterprise gate on that same path raised a bare `ValueError`, which the terminal handler turns into a 401. Every sibling enterprise gate answers 403, and a 401 tells the client to retry with a better credential, which no credential can satisfy while the install is unlicensed. It now raises a 403 `ProxyException` like the SSO gate does.
This commit is contained in:
parent
5aeb34b58c
commit
1e265dc86c
2 changed files with 114 additions and 3 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue