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:
Yassin Kortam 2026-08-04 20:05:03 -07:00 committed by GitHub
parent 5aeb34b58c
commit 1e265dc86c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 114 additions and 3 deletions

View file

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

View file

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