From f7849f9e91cbdba8bee8d053214c1f1a95c22e03 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Tue, 14 Jul 2026 17:05:05 -0700 Subject: [PATCH] fix(auth): scope the JWT enterprise gate to actual JWTs (#33296) With enable_jwt_auth enabled but no enterprise license (premium_user False), the JWT premium check fired on every request before the token was inspected, so the master key, sk- virtual keys, and the encrypted CLI/UI SSO session token that `lite login` issues all 401'd with "JWT Auth is an enterprise only feature" and were never decoded. That broke `lite login`, `lite claude`, and the proxy master key on any deployment that turned JWT auth on without a license. Move the premium check inside the is_jwt branch so it gates only real JWTs. Non-JWT credentials fall through to their own auth paths regardless of license; actual JWTs still require premium, so the enterprise gate is unchanged for the feature it protects. --- litellm/proxy/auth/user_api_key_auth.py | 10 ++- .../proxy/auth/test_user_api_key_auth.py | 74 +++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 744d8182715..b402212fb2e 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1191,13 +1191,15 @@ async def _user_api_key_auth_builder( return await handle_oauth2_proxy_request(request=request) if general_settings.get("enable_jwt_auth", False) is True: - 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}") is_jwt = jwt_handler.is_jwt(token=api_key) verbose_proxy_logger.debug("is_jwt: %s", is_jwt) if is_jwt: + 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}" + ) # Try JWT-to-Virtual-Key mapping first to avoid # unnecessary DB queries in auth_builder do_standard_jwt_auth = True 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 90f46152837..a0248963cf1 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 @@ -3992,6 +3992,80 @@ async def test_non_admin_cli_session_token_reaches_production_auth_path(monkeypa assert result.is_session_token is True +@pytest.mark.asyncio +async def test_cli_session_token_authenticates_when_jwt_auth_enabled_without_license(monkeypatch): + """A lite login token is an encrypted (non-JWT) session blob. With + enable_jwt_auth on and no enterprise license (premium_user False), the JWT + premium gate used to fire for every request before the token was decoded, so + the CLI token 401'd with 'JWT Auth is an enterprise only feature' and was + never decrypted. The gate must apply only to actual JWTs; a non-JWT session + token has to keep authenticating on its own path regardless of license.""" + monkeypatch.delenv("EXPERIMENTAL_UI_LOGIN", raising=False) + cli_token = _mint_cli_session_token(monkeypatch) + + jwt_handler = MagicMock() + jwt_handler.is_jwt = JWTHandler.is_jwt + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth() + + mock_request = MagicMock() + mock_request.url.path = "/v1/messages" + mock_request.method = "POST" + mock_request.headers = {"authorization": f"Bearer {cli_token}"} + mock_request.query_params = {} + + with ( + patch("litellm.proxy.proxy_server.general_settings", {"enable_jwt_auth": True}), + patch("litellm.proxy.proxy_server.premium_user", False), + patch("litellm.proxy.proxy_server.jwt_handler", jwt_handler), + patch("litellm.proxy.proxy_server.master_key", "sk-master"), + patch("litellm.proxy.proxy_server.prisma_client", None), + ): + result = await user_api_key_auth( + request=mock_request, + api_key=f"Bearer {cli_token}", + ) + + assert result.user_id == "cli-admin" + assert result.team_id == "cli-team" + assert result.token is not None and result.token.startswith("cli-session-") + + +@pytest.mark.asyncio +async def test_real_jwt_still_requires_license_when_jwt_auth_enabled(monkeypatch): + """Guard for the reorder above: the enterprise gate must still reject an + actual JWT when there is no license. Moving the premium check inside the + is_jwt branch must not open JWT auth to non-premium deployments.""" + monkeypatch.delenv("EXPERIMENTAL_UI_LOGIN", raising=False) + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-cli-test") + + jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.sig" + jwt_handler = MagicMock() + jwt_handler.is_jwt = JWTHandler.is_jwt + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth() + + mock_request = MagicMock() + mock_request.url.path = "/v1/messages" + mock_request.method = "POST" + mock_request.headers = {"authorization": f"Bearer {jwt_token}"} + mock_request.query_params = {} + + with ( + patch("litellm.proxy.proxy_server.general_settings", {"enable_jwt_auth": True}), + patch("litellm.proxy.proxy_server.premium_user", False), + patch("litellm.proxy.proxy_server.jwt_handler", jwt_handler), + patch("litellm.proxy.proxy_server.master_key", "sk-master"), + patch("litellm.proxy.proxy_server.prisma_client", None), + ): + with pytest.raises(Exception) as exc_info: + await user_api_key_auth( + request=mock_request, + api_key=f"Bearer {jwt_token}", + ) + + message = str(getattr(exc_info.value, "message", exc_info.value)) + assert "enterprise only feature" in message + + @pytest.mark.asyncio async def test_auth_path_caches_team_object_under_canonical_team_id_key(): """Regression for LIT-4000: the auth builder must cache the team object under