From 821628ac7e66ebdecb5c385c53fd6ca80b0a699c Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 1 Sep 2026 03:35:36 +0800 Subject: [PATCH] fix(proxy): use x-api-key for auth when Authorization is an Anthropic OAuth token The Claude Code (3P) tab in Claude Desktop sends the desktop session's Anthropic OAuth token (sk-ant-oat-...) in the Authorization header alongside the user's LiteLLM virtual key in x-api-key. get_api_key() gave Authorization unconditional precedence, so the proxy hashed the OAuth token, failed the LiteLLM_VerificationTokenTable lookup, and rejected the request with a 401 even though a valid key was present. sk-ant-oat* is an upstream passthrough credential the codebase already recognizes (is_anthropic_oauth_key); it is never a LiteLLM credential. When the Authorization bearer is such a token and a non-empty x-api-key is present, authenticate with x-api-key instead. Requests carrying only an Authorization header, only an x-api-key header, or a regular bearer key plus x-api-key are unchanged. Mirror the same rule in add_litellm_data_to_request's authenticated_with_header derivation so the OAuth token stays forwardable to Anthropic and the consumed virtual key is not forwarded upstream when forward_llm_provider_auth_headers is enabled. Fixes #29190 Co-Authored-By: Claude --- litellm/proxy/auth/user_api_key_auth.py | 21 ++- litellm/proxy/litellm_pre_call_utils.py | 14 +- .../proxy/auth/test_user_api_key_auth.py | 151 ++++++++++++++++++ .../proxy/test_litellm_pre_call_utils.py | 64 ++++++++ 4 files changed, 246 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index e92d090a2fb..a44a2225ce6 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -647,6 +647,7 @@ def get_api_key( Returns: Tuple[Optional[str], Optional[str]]: Tuple of the api_key and the passed_in_key """ + from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.common_utils.http_parsing_utils import ( _safe_get_request_query_params, @@ -658,8 +659,24 @@ def get_api_key( passed_in_key = custom_litellm_key_header api_key = _get_bearer_token_or_received_api_key(custom_litellm_key_header) elif isinstance(api_key, str) and len(api_key) > 0: - passed_in_key = api_key - api_key = _get_bearer_token(api_key=api_key) + bearer_api_key = _get_bearer_token(api_key=api_key) + if ( + is_anthropic_oauth_key(bearer_api_key) + and isinstance(anthropic_api_key_header, str) + and len(anthropic_api_key_header) > 0 + ): + # The Authorization header carries an upstream Anthropic OAuth token + # (`sk-ant-oat*`), which is never a LiteLLM credential. Clients like + # the Claude Code tab in Claude Desktop send that session token + # alongside the user's LiteLLM virtual key in `x-api-key` — use + # `x-api-key` for proxy auth instead of failing the OAuth token's + # lookup. The OAuth token itself stays on the request for the + # provider passthrough handling in litellm_pre_call_utils. + passed_in_key = anthropic_api_key_header + api_key = anthropic_api_key_header + else: + passed_in_key = api_key + api_key = bearer_api_key elif isinstance(azure_api_key_header, str): passed_in_key = azure_api_key_header api_key = azure_api_key_header diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index ae55b7ab906..3681a4faa74 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1741,13 +1741,23 @@ async def add_litellm_data_to_request( forward_llm_auth = getattr(litellm, "forward_llm_provider_auth_headers", False) # Determine which header was used for authentication # This enables forwarding provider keys (e.g., x-api-key) when they weren't used for LiteLLM auth + from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key + authenticated_with_header = None if "x-litellm-api-key" in request.headers: # If x-litellm-api-key is present, it was used for auth authenticated_with_header = "x-litellm-api-key" elif "authorization" in request.headers: - # Authorization header was used for auth - authenticated_with_header = "authorization" + if is_anthropic_oauth_key(request.headers.get("authorization")) and request.headers.get("x-api-key"): + # Authorization carries an upstream Anthropic OAuth token + # (`sk-ant-oat*`), so `user_api_key_auth.get_api_key` authenticated + # with `x-api-key` instead (see #29190). Mirror that here so the + # OAuth token stays forwardable to Anthropic and the LiteLLM + # virtual key in `x-api-key` is not forwarded upstream. + authenticated_with_header = "x-api-key" + else: + # Authorization header was used for auth + authenticated_with_header = "authorization" else: # x-api-key or another header was used for auth authenticated_with_header = "x-api-key" 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 d44f96d95bf..5eb8ac24db1 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 @@ -68,6 +68,87 @@ def test_get_api_key(): ) == (api_key, passed_in_key) +_ANTHROPIC_OAUTH_BEARER = "Bearer sk-ant-oat01-fake-claude-desktop-session-token" + + +def _get_api_key_result( + api_key=None, + anthropic_api_key_header=None, + custom_litellm_key_header=None, + route="/v1/messages", +): + return get_api_key( + custom_litellm_key_header=custom_litellm_key_header, + api_key=api_key, + azure_api_key_header=None, + anthropic_api_key_header=anthropic_api_key_header, + google_ai_studio_api_key_header=None, + azure_apim_header=None, + pass_through_endpoints=None, + route=route, + request=MagicMock(), + ) + + +def test_get_api_key_prefers_x_api_key_over_anthropic_oauth_authorization(): + """Claude Code (the Claude Desktop 3P tab) sends its Anthropic OAuth session + token in `Authorization` alongside the user's LiteLLM virtual key in + `x-api-key`. `sk-ant-oat*` is an upstream credential, never a LiteLLM key — + proxy auth must use `x-api-key`. Regression test for #29190.""" + litellm_key = "sk-my-litellm-virtual-key" + assert _get_api_key_result( + api_key=_ANTHROPIC_OAUTH_BEARER, + anthropic_api_key_header=litellm_key, + ) == (litellm_key, litellm_key) + + +def test_get_api_key_authorization_still_wins_over_x_api_key_for_regular_keys(): + """A regular bearer key in `Authorization` keeps precedence over `x-api-key`.""" + assert _get_api_key_result( + api_key="Bearer sk-regular-litellm-key", + anthropic_api_key_header="sk-some-other-key", + ) == ("sk-regular-litellm-key", "Bearer sk-regular-litellm-key") + + +def test_get_api_key_oauth_authorization_without_x_api_key_unchanged(): + """With no `x-api-key` fallback available, the OAuth bearer is still treated + as the (failing) auth credential — existing behavior preserved.""" + assert _get_api_key_result(api_key=_ANTHROPIC_OAUTH_BEARER) == ( + "sk-ant-oat01-fake-claude-desktop-session-token", + _ANTHROPIC_OAUTH_BEARER, + ) + + +def test_get_api_key_oauth_authorization_with_empty_x_api_key_unchanged(): + """An empty `x-api-key` is not a usable fallback credential.""" + assert _get_api_key_result( + api_key=_ANTHROPIC_OAUTH_BEARER, + anthropic_api_key_header="", + ) == ( + "sk-ant-oat01-fake-claude-desktop-session-token", + _ANTHROPIC_OAUTH_BEARER, + ) + + +def test_get_api_key_x_api_key_only_unchanged(): + """`x-api-key` alone keeps working (Claude Code CLI / Cowork tab).""" + litellm_key = "sk-my-litellm-virtual-key" + assert _get_api_key_result(anthropic_api_key_header=litellm_key) == ( + litellm_key, + litellm_key, + ) + + +def test_get_api_key_custom_litellm_key_header_still_supersedes_oauth_handling(): + """`x-litellm-api-key` keeps top precedence even when the OAuth + x-api-key + combination is present.""" + assert _get_api_key_result( + api_key=_ANTHROPIC_OAUTH_BEARER, + anthropic_api_key_header="sk-my-litellm-virtual-key", + custom_litellm_key_header="Bearer sk-custom-header-key", + ) == ("sk-custom-header-key", "Bearer sk-custom-header-key") + + def test_route_requires_auth_despite_public_for_metrics(monkeypatch): monkeypatch.setattr(litellm, "require_auth_for_metrics_endpoint", True) @@ -1521,6 +1602,76 @@ async def test_master_key_auth_sets_via_virtual_key_marker(): setattr(_proxy_server_mod, attr, val) +@pytest.mark.asyncio +async def test_auth_builder_dual_headers_authenticates_via_x_api_key_when_authorization_is_anthropic_oauth(): + """End-to-end regression test for #29190: `Authorization: Bearer sk-ant-oat...` + (the Claude Desktop session's Anthropic OAuth token) together with a valid + LiteLLM key in `x-api-key` must authenticate via `x-api-key` instead of + failing the OAuth token's virtual-key lookup.""" + from fastapi import Request + from starlette.datastructures import URL + + from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS + from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder + + master_key = "sk-master-key" + + mock_cache = AsyncMock() + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.delete_cache = MagicMock() + + mock_proxy_logging_obj = MagicMock() + mock_proxy_logging_obj.internal_usage_cache = MagicMock() + mock_proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock() + mock_proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = ( + AsyncMock() + ) + mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None) + + import litellm.proxy.proxy_server as _proxy_server_mod + + _attrs_to_set = { + "prisma_client": MagicMock(), + "user_api_key_cache": mock_cache, + "proxy_logging_obj": mock_proxy_logging_obj, + "master_key": master_key, + "general_settings": {}, + "llm_model_list": [], + "llm_router": None, + "open_telemetry_logger": None, + "model_max_budget_limiter": MagicMock(), + "user_custom_auth": None, + "jwt_handler": None, + "litellm_proxy_admin_name": "admin", + } + _original_values = { + attr: getattr(_proxy_server_mod, attr, None) for attr in _attrs_to_set + } + try: + for attr, val in _attrs_to_set.items(): + setattr(_proxy_server_mod, attr, val) + + request = Request(scope={"type": "http"}) + request._url = URL(url="/v1/messages") + + result = await _user_api_key_auth_builder( + request=request, + api_key=_ANTHROPIC_OAUTH_BEARER, + azure_api_key_header="", + anthropic_api_key_header=master_key, + google_ai_studio_api_key_header=None, + azure_apim_header=None, + request_data={}, + ) + + assert isinstance(result, UserAPIKeyAuth) + assert result.user_role == LitellmUserRoles.PROXY_ADMIN + assert result.api_key == LITELLM_PROXY_MASTER_KEY_ALIAS + finally: + for attr, val in _original_values.items(): + setattr(_proxy_server_mod, attr, val) + + @pytest.mark.asyncio async def test_db_virtual_key_auth_sets_via_virtual_key_marker(): """via_virtual_key gates overwrite_user_with_key_hash stamping and is diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index ee0e2014951..dcdcc21a708 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -6506,6 +6506,70 @@ async def test_add_litellm_data_to_request_redacts_oauth_header_from_logging_cop ) +@pytest.mark.asyncio +async def test_add_litellm_data_to_request_dual_headers_keeps_oauth_forwardable_and_virtual_key_private(): + """Claude Code (Desktop 3P tab) sends `authorization: Bearer sk-ant-oat...` + plus the LiteLLM virtual key in `x-api-key`; proxy auth consumes `x-api-key` + (#29190). The OAuth token must stay scoped for Anthropic passthrough, and the + consumed virtual key must NOT be forwarded upstream even with + `forward_llm_provider_auth_headers` enabled.""" + virtual_key = "sk-litellm-virtual-key-lit29190" + request_mock = _make_request_mock( + "/v1/messages", + { + "content-type": "application/json", + "anthropic-version": "2023-06-01", + "authorization": _OAUTH_TOKEN, + "x-api-key": virtual_key, + }, + ) + + updated = await add_litellm_data_to_request( + data={"model": "anthropic-claude", "messages": [{"role": "user", "content": "hello"}]}, + request=request_mock, + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"), + proxy_config=MagicMock(), + general_settings={"forward_llm_provider_auth_headers": True}, + version="test-version", + ) + + from litellm.litellm_core_utils.get_provider_specific_headers import ( + ProviderSpecificHeaderUtils, + ) + + anthropic_headers = ProviderSpecificHeaderUtils.get_provider_specific_headers( + provider_specific_header=updated["provider_specific_header"], + custom_llm_provider="anthropic", + ) + assert anthropic_headers["authorization"] == _OAUTH_TOKEN + assert virtual_key not in json.dumps(updated["provider_specific_header"], default=repr) + + +@pytest.mark.asyncio +async def test_add_litellm_data_to_request_oauth_authorization_without_x_api_key_stays_consumed(): + """Without an `x-api-key`, the Authorization header (even OAuth-shaped) is + still the auth credential and is not forwarded — existing behavior.""" + request_mock = _make_request_mock( + "/v1/messages", + { + "content-type": "application/json", + "anthropic-version": "2023-06-01", + "authorization": _OAUTH_TOKEN, + }, + ) + + updated = await add_litellm_data_to_request( + data={"model": "anthropic-claude", "messages": [{"role": "user", "content": "hello"}]}, + request=request_mock, + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"), + proxy_config=MagicMock(), + general_settings={"forward_llm_provider_auth_headers": True}, + version="test-version", + ) + + assert "sk-ant-oat01" not in json.dumps(updated.get("provider_specific_header"), default=repr) + + @pytest.mark.asyncio async def test_add_litellm_data_to_request_keeps_every_forwarded_credential_out_of_logging_copies(): """Credentials kept for transport must not survive anywhere under proxy_server_request."""