fix(mcp): never promote caller oauth2 headers to the resolved credential on the v1 arm

This commit is contained in:
Tin Chi Lo 2026-07-20 19:33:46 -07:00
parent 48572c9516
commit 040aa9d896
2 changed files with 89 additions and 4 deletions

View file

@ -4755,12 +4755,19 @@ class MCPServerManager:
back with any header the resolver claimed already dropped. Unmigrated (v1) servers resolve
through the stored-token lookup instead, and a missing per-user credential raises the same
discovery challenge the MCPClient path serves, rather than egressing unauthenticated.
The resolved headers carry only credentials the gateway itself resolved (a stored per-user
token, a minted or exchanged token). Caller-supplied ``oauth2_headers`` are never promoted
into them: on the v2 arm they feed only subject-token extraction (the designed RFC 8693
input), and on the v1 arm their presence disables the stored lookup entirely, so a
caller's gateway credential can never displace a per-server BYOK header or leak upstream
as the resolved credential.
"""
spec = to_server_spec(mcp_server)
if spec is None:
stored_headers = await self._resolve_oauth2_headers_for_tool_call(
mcp_server, oauth2_headers, user_api_key_auth
)
if oauth2_headers:
return None, forwarded_headers
stored_headers = await self._resolve_oauth2_headers_for_tool_call(mcp_server, None, user_api_key_auth)
return stored_headers, forwarded_headers
subject_token: str | None = None
@ -4872,6 +4879,7 @@ class MCPServerManager:
)
tasks.append(during_hook_task)
caller_oauth2_headers = oauth2_headers
oauth2_headers = await self._resolve_oauth2_headers_for_tool_call(mcp_server, oauth2_headers, user_api_key_auth)
# For OpenAPI servers, call the tool handler directly instead of via MCP client
@ -4891,7 +4899,7 @@ class MCPServerManager:
)
resolved_auth_headers, forwarded_headers = await self.resolve_openapi_upstream_auth(
mcp_server=mcp_server,
oauth2_headers=oauth2_headers,
oauth2_headers=caller_oauth2_headers,
raw_headers=raw_headers,
mcp_auth_header=mcp_auth_header,
user_api_key_auth=user_api_key_auth,

View file

@ -1119,3 +1119,80 @@ class TestOpenApiResolvedUpstreamAuth:
)
called.assert_not_awaited()
@pytest.mark.asyncio
async def test_caller_oauth2_headers_never_become_resolved_for_byok_server(self):
"""Greptile P1 regression: BYOK servers defer to v1 (to_server_spec None), and the v1 arm
must never promote caller-supplied oauth2 headers into the resolved-auth slot, where they
would override the per-server BYOK credential and leak the caller's gateway Authorization
upstream."""
manager = MCPServerManager()
server = MCPServer(
server_id="byok-spec",
name="byok_spec",
server_name="byok_spec",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.api_key,
spec_path="https://example.com/openapi.yaml",
is_byok=True,
)
resolved, forwarded = await manager.resolve_openapi_upstream_auth(
mcp_server=server,
oauth2_headers={"Authorization": "Bearer sk-litellm-gateway-key"},
raw_headers=None,
mcp_auth_header="user-byok-key",
user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key="sk-user"),
forwarded_headers=None,
)
assert resolved is None
assert forwarded is None
@pytest.mark.asyncio
async def test_v1_server_threads_stored_headers_only_without_caller_headers(self):
"""The v1 (unmigrated) arm resolves the stored per-user token only when the caller sent no
oauth2 headers of their own; with caller headers present the stored lookup is skipped and
nothing is promoted to resolved."""
manager = MCPServerManager()
server = MCPServer(
server_id="v1-spec",
name="v1_spec",
server_name="v1_spec",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
spec_path="https://example.com/openapi.yaml",
delegate_auth_to_upstream=True,
)
stored = {"Authorization": "Bearer stored-v1-token"}
user_auth = UserAPIKeyAuth(user_id="alice", api_key="sk-user")
with patch.object(
manager, "_resolve_oauth2_headers_for_tool_call", new=AsyncMock(return_value=stored)
) as lookup:
resolved, _ = await manager.resolve_openapi_upstream_auth(
mcp_server=server,
oauth2_headers=None,
raw_headers=None,
mcp_auth_header=None,
user_api_key_auth=user_auth,
forwarded_headers=None,
)
assert resolved == stored
lookup.assert_awaited_once_with(server, None, user_auth)
with patch.object(
manager, "_resolve_oauth2_headers_for_tool_call", new=AsyncMock(return_value=stored)
) as lookup:
resolved, _ = await manager.resolve_openapi_upstream_auth(
mcp_server=server,
oauth2_headers={"Authorization": "Bearer caller-supplied"},
raw_headers=None,
mcp_auth_header=None,
user_api_key_auth=user_auth,
forwarded_headers=None,
)
assert resolved is None
lookup.assert_not_awaited()