Skip to content

Commit 6bf9565

Browse files
authored
fix(auth): preserve outer principal on MCP anonymous path (#1326)
* fix(auth): unify duplicate credential policy across transports * fix(auth): preserve outer principal on MCP anonymous path * fix(auth): retain safe validator error alerting
1 parent 6fa21d2 commit 6bf9565

3 files changed

Lines changed: 158 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## Unreleased
44

5+
### Bug fixes
6+
7+
* **auth:** MCP's `allow_unauthenticated` path preserves principal, tenant,
8+
and metadata established by outer middleware in request state or ContextVars,
9+
matching A2A. Explicit request state takes precedence, including anonymous
10+
state. Caller identity headers remain untrusted. Refs #1304 (outer identity).
11+
512
### Security fixes
613

714
* **auth:** MCP and A2A reject conflicting accepted credentials (401),

‎src/adcp/server/auth.py‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -557,17 +557,22 @@ async def dispatch(self, request: Request, call_next: Any) -> Any:
557557
_set_request_state(request, None, None, None)
558558
return await call_next(request)
559559
if self._allow_unauthenticated and signature_error is None:
560-
# Network-trust deployment: no bearer is expected on this
561-
# leg — the agent is reachable only via the host's
562-
# authenticated proxy, which propagates identity downstream
563-
# (e.g. X-Identity-* / X-Principal-Id). Pass through with no
564-
# principal, exactly like the discovery bypass; the app
565-
# resolves and enforces identity. A token that IS present but
566-
# invalid still falls through to rejection below.
567-
principal_token = current_principal.set(None)
568-
tenant_token = current_tenant.set(None)
569-
metadata_token = current_principal_metadata.set(None)
570-
_set_request_state(request, None, None, None)
560+
# Preserve identity explicitly established by an outer
561+
# auth layer. A present state carrier (including None)
562+
# takes precedence over ContextVars; never read identity
563+
# from arbitrary caller headers here.
564+
outer = _read_request_state_auth(request)
565+
if outer is None:
566+
outer = (
567+
current_principal.get(),
568+
current_tenant.get(),
569+
current_principal_metadata.get(),
570+
)
571+
principal_identity, tenant_id, principal_metadata = outer
572+
principal_token = current_principal.set(principal_identity)
573+
tenant_token = current_tenant.set(tenant_id)
574+
metadata_token = current_principal_metadata.set(principal_metadata)
575+
_set_request_state(request, *outer)
571576
return await call_next(request)
572577
return self._unauthenticated(signature_error)
573578

‎tests/test_auth_outer_principal.py‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Trusted outer identity survives the unauthenticated MCP gate (#1304a)."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from typing import Any
7+
8+
import httpx
9+
import pytest
10+
from starlette.applications import Starlette
11+
from starlette.requests import Request
12+
from starlette.responses import JSONResponse
13+
from starlette.routing import Route
14+
15+
from adcp.server.auth import (
16+
REQUEST_STATE_PRINCIPAL,
17+
REQUEST_STATE_PRINCIPAL_METADATA,
18+
REQUEST_STATE_TENANT,
19+
BearerTokenAuth,
20+
auth_context_factory,
21+
current_principal,
22+
current_principal_metadata,
23+
current_tenant,
24+
)
25+
from adcp.server.serve import RequestMetadata, _wrap_a2a_with_auth, _wrap_mcp_with_auth
26+
27+
28+
class TrustedIdentity:
29+
"""Test auth layer; only the test's trusted request flag creates identity."""
30+
31+
def __init__(self, app: Any, carrier: str, identity: str | None) -> None:
32+
self.app = app
33+
self.carrier = carrier
34+
self.identity = identity
35+
36+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
37+
if scope.get("type") != "http" or (b"x-trusted-test", b"yes") not in scope["headers"]:
38+
await self.app(scope, receive, send)
39+
return
40+
tokens = []
41+
if self.carrier in {"state", "both"}:
42+
scope.setdefault("state", {}).update(
43+
{
44+
REQUEST_STATE_PRINCIPAL: self.identity,
45+
REQUEST_STATE_TENANT: "tenant-42",
46+
REQUEST_STATE_PRINCIPAL_METADATA: {"role": "buyer"},
47+
}
48+
)
49+
if self.carrier in {"context", "both"}:
50+
tokens = [
51+
current_principal.set(self.identity),
52+
current_tenant.set("tenant-42"),
53+
current_principal_metadata.set({"role": "buyer"}),
54+
]
55+
try:
56+
await self.app(scope, receive, send)
57+
finally:
58+
if tokens:
59+
current_principal.reset(tokens[0])
60+
current_tenant.reset(tokens[1])
61+
current_principal_metadata.reset(tokens[2])
62+
63+
64+
def outer_app(leg: str, *, fail: bool = False, allow: bool = True) -> Any:
65+
async def endpoint(request: Request) -> JSONResponse:
66+
await asyncio.sleep(0)
67+
ctx = auth_context_factory(
68+
RequestMetadata(
69+
tool_name="get_products",
70+
transport=leg,
71+
request_id="test",
72+
request_context=request,
73+
)
74+
)
75+
if fail:
76+
raise RuntimeError("handler failed")
77+
return JSONResponse(
78+
{
79+
"principal": ctx.caller_identity,
80+
"tenant": ctx.tenant_id,
81+
"role": ctx.metadata.get("role"),
82+
}
83+
)
84+
85+
inner = Starlette(routes=[Route("/", endpoint, methods=["POST"])])
86+
config = BearerTokenAuth(validate_token=lambda token: None, allow_unauthenticated=allow)
87+
return (_wrap_mcp_with_auth if leg == "mcp" else _wrap_a2a_with_auth)(inner, config)
88+
89+
90+
CALL = {"method": "tools/call", "params": {"name": "get_products"}}
91+
92+
93+
@pytest.mark.asyncio
94+
@pytest.mark.parametrize("leg", ["mcp", "a2a"])
95+
@pytest.mark.parametrize("carrier", ["state", "context", "both"])
96+
@pytest.mark.parametrize("identity", ["buyer-42", "", None])
97+
async def test_preserve_outer_identity(leg: str, carrier: str, identity: str | None) -> None:
98+
app = TrustedIdentity(outer_app(leg), carrier, identity)
99+
async with httpx.AsyncClient(
100+
transport=httpx.ASGITransport(app=app), base_url="http://test"
101+
) as client:
102+
identified = await client.post("/", json=CALL, headers={"x-trusted-test": "yes"})
103+
assert identified.json() == {"principal": identity, "tenant": "tenant-42", "role": "buyer"}
104+
anonymous = await client.post("/", json=CALL)
105+
assert anonymous.json() == {"principal": None, "tenant": None, "role": None}
106+
assert current_principal.get() is None
107+
assert current_tenant.get() is None
108+
assert current_principal_metadata.get() is None
109+
110+
111+
@pytest.mark.asyncio
112+
@pytest.mark.parametrize("leg", ["mcp", "a2a"])
113+
async def test_reset_even_when_handler_raises(leg: str) -> None:
114+
app = TrustedIdentity(outer_app(leg, fail=True), "both", "buyer-42")
115+
async with httpx.AsyncClient(
116+
transport=httpx.ASGITransport(app=app), base_url="http://test"
117+
) as client:
118+
with pytest.raises(RuntimeError, match="handler failed"):
119+
await client.post("/", json=CALL, headers={"x-trusted-test": "yes"})
120+
assert current_principal.get() is None
121+
assert current_tenant.get() is None
122+
assert current_principal_metadata.get() is None
123+
124+
125+
@pytest.mark.asyncio
126+
@pytest.mark.parametrize("leg", ["mcp", "a2a"])
127+
@pytest.mark.parametrize("allow", [False, True])
128+
async def test_caller_identity_header_is_not_trusted(leg: str, allow: bool) -> None:
129+
async with httpx.AsyncClient(
130+
transport=httpx.ASGITransport(app=outer_app(leg, allow=allow)), base_url="http://test"
131+
) as client:
132+
response = await client.post("/", json=CALL, headers={"x-principal-id": "forged"})
133+
assert response.status_code == (200 if allow else 401)
134+
if allow:
135+
assert response.json()["principal"] is None

0 commit comments

Comments
 (0)