This commit is contained in:
zerone0x 2026-09-23 14:49:27 +00:00 • committed by GitHub
commit 98e74396cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 246 additions and 4 deletions

View file

@ -803,6 +803,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,
@ -814,8 +815,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

View file

@ -2006,13 +2006,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"

View file

@ -88,6 +88,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)
@ -1570,6 +1651,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

View file

@ -6692,6 +6692,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."""