Repository navigation
feat(auth): let BearerTokenAuth accept a non-bearer principal resolver (trusted-proxy / embedded identity) on both legs #1304
Description
Summary
BearerTokenAuth can only authenticate a request by passing a bearer string to validate_token. For callers whose identity comes from somewhere else (headers injected by a trusted proxy, an mTLS client certificate, a host-signed identity token), the only option is allow_unauthenticated=True (adcp/server/auth.py:1082-1089). That setting:
- applies to every request in the process, so one server can't require bearer tokens for some tenants and trust proxy identity for others;
- passes the request through with no principal (
auth.py:496-508MCP,:1601-1607A2A). Idempotency scoping and tenant enforcement then seecaller_identity=Noneunless the seller resolves identity again in the synchronouscontext_factory, which runs per tool call after the transport has accepted the request; - on the MCP leg, sets
current_principalandrequest.statetoNone. Any identity an outerasgi_middlewarealready resolved is lost. The A2A leg leaves it alone.
The serve() docs suggest "For non-bearer schemes (mTLS), wire your own middleware via asgi_middleware=" (adcp/server/serve.py:1075). But that middleware can't hand a principal to the SDK in a supported way. The one existing non-bearer path, verified RFC 9421 signers, is reserved for the framework (adcp/server/signed_requests.py:546-553).
Reproduction (adcp 8.0.0)
import json
from starlette.testclient import TestClient
from adcp.server import ADCPHandler
from adcp.server.auth import (
REQUEST_STATE_PRINCIPAL,
BearerTokenAuth,
auth_context_factory,
current_principal,
)
from adcp.server.serve import _build_mcp_and_a2a_app # the builder serve(transport="both") uses
seen = []
class Seller(ADCPHandler):
async def get_products(self, params, context=None):
seen.append(context.caller_identity)
return {"products": []}
class TrustedHeaderIdentity:
"""Adopter middleware: the trusted proxy has authenticated the caller."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
principal = dict(scope.get("headers", ())).get(b"x-principal-id")
if scope["type"] == "http" and principal:
scope.setdefault("state", {})[REQUEST_STATE_PRINCIPAL] = principal.decode()
current_principal.set(principal.decode())
await self.app(scope, receive, send)
def build(allow_unauthenticated):
app = _build_mcp_and_a2a_app(
Seller(), name="seller", port=3001, host="0.0.0.0", instructions=None,
test_controller=None, validation=None, stateless_http=True,
enable_dns_rebinding_protection=False, context_factory=auth_context_factory,
auth=BearerTokenAuth(validate_token=lambda token: None,
allow_unauthenticated=allow_unauthenticated),
)
return TrustedHeaderIdentity(app)
HEADERS = {"content-type": "application/json", "accept": "application/json, text/event-stream",
"x-principal-id": "buyer-42", "A2A-Version": "1.0"}
PARAMS = {"buying_mode": "brief", "brief": "video"}
MCP_CALL = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "get_products", "arguments": PARAMS}}
A2A_CALL = {"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {"message": {
"messageId": "m1", "role": "ROLE_USER",
"parts": [{"data": {"skill": "get_products", "parameters": PARAMS}}]}}}
for allow in (False, True):
with TestClient(build(allow)) as client:
for leg, path, body in (("MCP", "/mcp", MCP_CALL), ("A2A", "/", A2A_CALL)):
seen.clear()
response = client.post(path, headers=HEADERS, content=json.dumps(body))
print(f"allow_unauthenticated={allow!s:5} {leg}: HTTP {response.status_code}, "
f"handler caller_identity={seen[0] if seen else '(handler not called)'!r}")Output:
allow_unauthenticated=False MCP: HTTP 401, handler caller_identity='(handler not called)'
allow_unauthenticated=False A2A: HTTP 401, handler caller_identity='(handler not called)'
allow_unauthenticated=True MCP: HTTP 200, handler caller_identity=None
allow_unauthenticated=True A2A: HTTP 200, handler caller_identity='buyer-42'
Why an adopter hits this
Embedded Sales Agent runs bearer-token tenants and embedded tenants in one process. For embedded tenants, the host's proxy injects validated identity headers. allow_unauthenticated would open the bearer-less path for every tenant, so ESA leaves it off by default. Instead, EmbeddedBuyerAuthBridgeMiddleware (src/core/middleware/embedded_buyer_auth_bridge.py, 190 LOC) does the following:
- resolves the embedded identity before the SDK auth runs;
- mints an opaque process-local token;
- rewrites
Authorization: Bearer <token>, so that the SDK'svalidate_tokenmaps the token back to the principal.
That is a synthetic credential created only to get through the auth gate.
Ask
Add an optional resolver to BearerTokenAuth that runs on both legs before the bearer check:
BearerTokenAuth(
validate_token=...,
resolve_principal=lambda request: Principal | None, # sync or async
)If it returns a Principal, install it exactly as a validated bearer would be: ContextVars, request.state, and scope["user"] on A2A. If it returns None, apply the normal bearer rules. It should also be able to raise to reject the request with 401/403.
Separately, the MCP allow_unauthenticated branch should not overwrite identity that an outer layer has already set, so the two legs behave the same.
Activity
bokelley commentedon Oct 2, 2026
Triage
Classification: Feature request
Bucket(s): middleware (auth layer — no exact label match found; flagged in run summary)
Status: ready-for-human
What the experts said:
allow_unauthenticatedfix is correct and safe;resolve_principalmust use a typedPrincipalResolverErrorso raise-to-reject never leaks resolver internals into HTTP response bodies; presence-not-truthiness check for outer identity in theallow_unauthenticatedfix.allow_unauthenticatedfix is clean patch-level alignment (nit);resolve_principalhas a structural blocker —A2ABearerAuthMiddleware.__call__receives raw ASGI scope, not a StarletteRequest, so a single callback type cannot run identically on both legs without a per-leg split or thin adapter._authenticate_scopeis sync — async resolvers cannot be awaited there, requiring a design decision between sync-only restriction or moving the call into__call__; (2) constructingRequest(scope)withoutreceiveon the A2A leg creates a body-consumption hazard.My take: The
allow_unauthenticatedfix is a clean isolated patch (auth.py:496–508) and can land independently without theresolve_principalfeature. Theresolve_principalAPI shape is well-motivated but requires three design decisions before implementation starts.Three questions for the maintainer:
scopedict,Request(scope)(body-access hazard), or a per-leg split matching the existingmcp_header_name/a2a_header_namepattern?resolve_principalstill run, or does bearer win?PrincipalResolverError(status: int)to carry 403 for known-but-unauthorized? Resolver exception text must never appear in the HTTP response body.Triaged by Claude Code. Session: https://claude.ai/code/session_01YXYSgw4eqmUHMNcZUGVDgs
Generated by Claude Code
bokelley commentedon Oct 4, 2026
Completed by #1326 (outer principal preserved on the MCP anonymous path, squash 6bf9565) and #1328 (non-bearer principal resolver on both transports).