Skip to content

Commit 4ead607

Browse files
bokelleyclaude
andauthored
feat(auth): serve(auth=BearerTokenAuth(...)) — A2A sibling + cross-transport shortcut (#566)
* feat(auth): serve(auth=BearerTokenAuth(...)) — A2A sibling + cross-transport shortcut Closes #558. BearerTokenAuthMiddleware only protected the MCP leg. serve(auth=BearerTokenAuth(...)) wires both transports from one config. Adds: BearerTokenAuth dataclass, A2ABearerAuthMiddleware (path-exempts agent-card per A2A spec 4.1, returns HTTP 401, stashes scope['user']), and the serve(auth=) kwarg. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): expert-review fixes for #566 (RFC 6750, async-validator boot reject, OPTIONS, telemetry) Address findings from code-reviewer / security-reviewer / ad-tech-protocol-expert review: - WWW-Authenticate header on every 401 (RFC 7235 §3.1, RFC 6750 §3). Browsers and HTTP libraries that follow the spec now surface the bearer challenge instead of treating the 401 as opaque. - 401 body uses RFC 6750 §3.1 error codes ("invalid_token", "error_description") instead of free-form "unauthenticated". - OPTIONS preflight bypasses auth so CORS works for browser-origin buyers — without this the preflight 401s and the buyer never gets the chance to retry with a token. - Async validator now rejected at boot in _wrap_a2a_with_auth via inspect.iscoroutinefunction. Production deployments fail at serve() time instead of on first traffic. - Auth-rejection telemetry: each rejection branch logs a coarse reason (missing_header / wrong_scheme / invalid_token / etc.) so SOC dashboards can detect scanning. Validator exceptions still log exception() for the operator stack. - mcp_inner = _wrap_mcp_with_auth(mcp_inner, auth) — assign back so a future refactor switching to fresh-callable wrappers doesn't silently drop auth. - serve(auth: BearerTokenAuth | None = None) typed instead of Any. - Tests added: WWW-Authenticate header, RFC 6750 body shape, OPTIONS bypass, async-validator boot rejection, sync validator passes boot, validator-exception 401 through full ASGI stack. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): drift-guard _A2A_DISCOVERY_PATHS against a2a-sdk route renames Reviewer flagged that the hardcoded /.well-known/agent-card.json literal could silently leak auth on a renamed route if a2a-sdk's canonical path moves. Two changes: 1. Reference a2a.utils.constants.AGENT_CARD_WELL_KNOWN_PATH directly so the 1.0 path tracks a2a-sdk automatically. Legacy 0.3 alias /.well-known/agent.json stays as a literal (no constant for it). 2. New test_discovery_paths_match_a2a_sdk_routes inspects every path that create_agent_card_routes registers and asserts each is in _A2A_DISCOVERY_PATHS. If a future a2a-sdk version adds a new well-known route (extensions, capability descriptor, etc.), this test fails first — adopters update the frozenset rather than silently 401'ing on the renamed/added route. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b1fe9d5 commit 4ead607

5 files changed

Lines changed: 1128 additions & 12 deletions

File tree

src/adcp/server/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ async def get_products(params, context=None):
5555
from adcp.capabilities import validate_capabilities
5656
from adcp.server.a2a_server import ADCPAgentExecutor, MessageParser, create_a2a_server
5757
from adcp.server.auth import (
58+
A2ABearerAuthMiddleware,
5859
AsyncTokenValidator,
60+
BearerTokenAuth,
5961
BearerTokenAuthMiddleware,
6062
Principal,
6163
SyncTokenValidator,
@@ -194,7 +196,9 @@ async def get_products(params, context=None):
194196
"SkillMiddleware",
195197
"create_a2a_server",
196198
# Bearer-token auth middleware (seller-facing recipe)
199+
"A2ABearerAuthMiddleware",
197200
"AsyncTokenValidator",
201+
"BearerTokenAuth",
198202
"BearerTokenAuthMiddleware",
199203
"Principal",
200204
"SyncTokenValidator",

src/adcp/server/a2a_server.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,7 @@ def create_a2a_server(
682682
message_parser: MessageParser | None = None,
683683
advertise_all: bool = False,
684684
validation: ValidationHookConfig | None = SERVER_DEFAULT_VALIDATION,
685+
context_builder: Any | None = None,
685686
) -> Any:
686687
"""Create an A2A Starlette application from an ADCP handler.
687688
@@ -812,12 +813,25 @@ def create_a2a_server(
812813
# ``enable_v0_3_compat=True`` is load-bearing: it makes the server
813814
# dual-serve 0.3 and 1.0 wire formats on the same endpoint so existing
814815
# 0.3 buyer clients keep working unchanged. Do not disable.
816+
#
817+
# ``context_builder`` is the a2a-sdk seam for customising the
818+
# :class:`ServerCallContext` each handler receives. We thread it
819+
# through verbatim when supplied — bearer-token auth is wired
820+
# separately via :class:`A2ABearerAuthMiddleware` at the ASGI
821+
# layer (see ``serve.py:_wrap_a2a_with_auth``) because the v0.3
822+
# compat adapter swallows builder-raised ``HTTPException``s. The
823+
# builder kwarg remains for adopters customising the
824+
# ``ServerCallContext`` shape (e.g. surfacing additional
825+
# ``state`` fields from the request).
826+
jsonrpc_kwargs: dict[str, Any] = {
827+
"request_handler": request_handler,
828+
"rpc_url": "/",
829+
"enable_v0_3_compat": True,
830+
}
831+
if context_builder is not None:
832+
jsonrpc_kwargs["context_builder"] = context_builder
815833
routes = list(create_agent_card_routes(agent_card=agent_card)) + list(
816-
create_jsonrpc_routes(
817-
request_handler=request_handler,
818-
rpc_url="/",
819-
enable_v0_3_compat=True,
820-
)
834+
create_jsonrpc_routes(**jsonrpc_kwargs)
821835
)
822836
app = Starlette(routes=routes)
823837

src/adcp/server/auth.py

Lines changed: 274 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,15 @@ async def validate_token(token: str) -> Principal | None:
5959
* **Authorization.** The middleware answers "who is this?", not "can
6060
they do X?". Authorization checks run on the authenticated principal
6161
inside your handlers or as :data:`~adcp.server.SkillMiddleware`.
62-
* **A2A auth.** A2A uses a different transport; wire a2a-sdk's
63-
``ServerCallContext.user`` via a2a-sdk auth middleware on that side.
64-
The ``Principal`` / ``ToolContext`` shape is the same, so handlers
65-
work unchanged across transports.
62+
* **A2A auth.** A2A uses a different transport; the same
63+
:class:`BearerTokenAuth` config object drives both legs when wired
64+
via :func:`adcp.server.serve`'s ``auth=`` kwarg. The A2A side is
65+
authenticated by a :class:`BearerTokenContextBuilder` plumbed into
66+
``a2a-sdk``'s ``create_jsonrpc_routes(context_builder=...)`` seam,
67+
not by a Starlette middleware — that placement bypasses the
68+
``/.well-known/agent-card.json`` route automatically (which is
69+
registered separately and never invokes the builder), satisfying
70+
A2A spec §4.1's mandate that the agent card be publicly accessible.
6671
"""
6772

6873
from __future__ import annotations
@@ -480,3 +485,268 @@ def _validate(token: str) -> Principal | None:
480485
return constant_time_token_match(token, stored_hashes)
481486

482487
return _validate
488+
489+
490+
# ---------------------------------------------------------------------------
491+
# Cross-transport auth config — drives both MCP middleware and A2A builder
492+
# ---------------------------------------------------------------------------
493+
494+
495+
@dataclass(frozen=True)
496+
class BearerTokenAuth:
497+
"""Cross-transport bearer-token auth config for :func:`adcp.server.serve`.
498+
499+
Single source of truth that wires the same ``validate_token``
500+
callback (and ``header_name`` / ``bearer_prefix_required`` knobs)
501+
into both the MCP-side :class:`BearerTokenAuthMiddleware` and the
502+
A2A-side :class:`BearerTokenContextBuilder`. Pass via
503+
``serve(auth=BearerTokenAuth(...))`` and both legs are
504+
authenticated against the same token store with no per-leg
505+
drift::
506+
507+
from adcp.server import serve
508+
from adcp.server.auth import BearerTokenAuth, validator_from_token_map
509+
510+
serve(
511+
handler,
512+
transport="both",
513+
auth=BearerTokenAuth(
514+
validate_token=validator_from_token_map({
515+
"secret-token": Principal(caller_identity="p", tenant_id="acme"),
516+
}),
517+
),
518+
)
519+
520+
On MCP, requests without a valid token receive a JSON ``401``
521+
body. On A2A, requests without a valid token receive an HTTP
522+
``401`` from Starlette's :class:`HTTPException`. Discovery
523+
bypasses are transport-specific:
524+
525+
* **MCP**: ``initialize`` / ``tools/list`` / ``notifications/initialized``
526+
/ ``get_adcp_capabilities`` (JSON-RPC method-level bypass).
527+
* **A2A**: ``/.well-known/agent-card.json`` (route-level — the
528+
agent-card route is created separately and never invokes the
529+
builder, so no path-based exemption is needed in the
530+
:class:`BearerTokenContextBuilder` itself).
531+
532+
Knobs mirror :class:`BearerTokenAuthMiddleware` exactly: pass
533+
``header_name="x-adcp-auth"`` and ``bearer_prefix_required=False``
534+
for non-OAuth custom-header schemes.
535+
"""
536+
537+
validate_token: TokenValidator
538+
header_name: str = "authorization"
539+
bearer_prefix_required: bool = True
540+
unauthenticated_response: dict[str, Any] | None = None
541+
542+
543+
# ---------------------------------------------------------------------------
544+
# A2A: ASGI middleware that gates JSON-RPC requests, exempts agent-card
545+
# ---------------------------------------------------------------------------
546+
#
547+
# Why an ASGI middleware (not a ServerCallContextBuilder)?
548+
# The a2a-sdk v0.3 compat adapter wraps the entire dispatch in
549+
# ``except Exception`` and converts any error — including a builder-
550+
# raised :class:`HTTPException(401)` — into a 200 OK with a JSON-RPC
551+
# error body. That breaks the spec-canonical HTTP 401 contract and
552+
# leaks the auth path as a 200. Authenticating outside the dispatcher,
553+
# at the ASGI layer, returns proper HTTP 401 every time.
554+
#
555+
# A2A discovery (``/.well-known/agent-card.json``) is exempted by URL
556+
# path here because the agent-card route happens to live in the same
557+
# Starlette app — the middleware can't rely on the route topology
558+
# alone. Path-exemption keeps the spec §4.1 public-discovery mandate
559+
# satisfied even if a future a2a-sdk refactor merges the routes.
560+
561+
562+
# Canonical 1.0 path is sourced from a2a-sdk's own constant — if a
563+
# future a2a-sdk release renames the well-known URI, the import-time
564+
# reference here lifts to the new value automatically and
565+
# ``test_discovery_paths_match_a2a_sdk_routes`` verifies that the
566+
# frozenset still covers every route ``create_agent_card_routes``
567+
# actually registers. Hardcoding the string would silently leak auth
568+
# on the renamed route until someone notices.
569+
from a2a.utils.constants import ( # noqa: E402 (intentional placement after BearerTokenAuth definition)
570+
AGENT_CARD_WELL_KNOWN_PATH as _A2A_AGENT_CARD_PATH,
571+
)
572+
573+
_A2A_DISCOVERY_PATHS: frozenset[str] = frozenset(
574+
{
575+
_A2A_AGENT_CARD_PATH, # 1.0 canonical: ``/.well-known/agent-card.json``.
576+
"/.well-known/agent.json", # Legacy 0.3 alias retained by enable_v0_3_compat=True.
577+
}
578+
)
579+
580+
581+
class A2ABearerAuthMiddleware:
582+
"""Pure-ASGI middleware that gates A2A JSON-RPC on a bearer token.
583+
584+
Wrap the Starlette app produced by
585+
:func:`adcp.server.a2a_server.create_a2a_server` with this
586+
middleware to require a valid bearer header on every JSON-RPC
587+
request, while leaving the spec-mandated public discovery
588+
surface (``/.well-known/agent-card.json``) accessible.
589+
590+
Designed to compose with a2a-sdk's
591+
:class:`DefaultServerCallContextBuilder`: on auth success the
592+
middleware writes a duck-typed user object into
593+
``scope['user']`` and the principal into ``scope['auth']``,
594+
matching Starlette's :class:`AuthenticationMiddleware` contract.
595+
The default builder reads ``scope['user']`` and adapts it via
596+
:class:`a2a.server.routes.common.StarletteUser`, so downstream
597+
handlers see ``ServerCallContext.user.user_name`` populated with
598+
the principal's ``caller_identity`` without a custom builder.
599+
600+
Composition order matters when ``transport="both"`` is in play:
601+
wrap the per-leg apps before any outer dispatcher closes over
602+
them. See ``serve.py:_build_mcp_and_a2a_app`` for the wiring.
603+
"""
604+
605+
def __init__(self, app: Any, config: BearerTokenAuth) -> None:
606+
self._app = app
607+
self._config = config
608+
self._header_name = config.header_name.lower()
609+
610+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
611+
# Lifespan + websocket pass through unchanged. Auth applies to
612+
# HTTP requests only.
613+
if scope.get("type") != "http":
614+
await self._app(scope, receive, send)
615+
return
616+
617+
# CORS preflight is part of the public surface — browser-origin
618+
# clients send ``OPTIONS`` before any auth'd POST. Returning 401
619+
# here breaks the preflight and the buyer never gets a chance to
620+
# retry with a token. Pass through; let the inner app's CORS
621+
# handler (or operator-supplied ``asgi_middleware``) respond.
622+
if scope.get("method") == "OPTIONS":
623+
await self._app(scope, receive, send)
624+
return
625+
626+
path = scope.get("path", "")
627+
if path in _A2A_DISCOVERY_PATHS:
628+
await self._app(scope, receive, send)
629+
return
630+
631+
principal = self._authenticate_scope(scope)
632+
if principal is None:
633+
await self._send_unauthenticated(send)
634+
return
635+
636+
# Stash both the duck-typed user (for DefaultServerCallContextBuilder)
637+
# and the raw Principal (for downstream code reading scope['auth']).
638+
# Mutating the scope dict before delegating propagates state to
639+
# nested apps without copying.
640+
scope["user"] = _A2AAuthenticatedUser(
641+
display_name=principal.caller_identity,
642+
tenant_id=principal.tenant_id,
643+
principal_metadata=dict(principal.metadata) if principal.metadata else None,
644+
)
645+
scope["auth"] = principal
646+
await self._app(scope, receive, send)
647+
648+
def _authenticate_scope(self, scope: Any) -> Principal | None:
649+
"""Read + validate the bearer header off raw ASGI scope.
650+
651+
Validator exceptions are projected to :data:`None` (logged for
652+
operators) so a buggy validator never leaks 500-level stack
653+
traces or signals path existence to unauthenticated callers.
654+
Auth-rejection branches log at INFO with a coarse reason code
655+
so SOC dashboards can detect scanning without bloating logs.
656+
"""
657+
# ASGI ``headers`` is a list of ``(bytes_lower, bytes)`` tuples.
658+
target = self._header_name.encode("latin-1")
659+
raw_value: bytes | None = None
660+
for name, value in scope.get("headers", ()):
661+
if name == target:
662+
raw_value = value
663+
break
664+
665+
if raw_value is None:
666+
logger.info("a2a auth rejected", extra={"reason": "missing_header"})
667+
return None
668+
669+
try:
670+
raw_header = raw_value.decode("latin-1")
671+
except UnicodeDecodeError:
672+
logger.info("a2a auth rejected", extra={"reason": "header_decode"})
673+
return None
674+
675+
if self._config.bearer_prefix_required:
676+
bearer = _parse_bearer_header(raw_header)
677+
else:
678+
stripped = raw_header.strip()
679+
bearer = stripped or None
680+
if not bearer:
681+
logger.info("a2a auth rejected", extra={"reason": "wrong_scheme"})
682+
return None
683+
684+
try:
685+
raw = self._config.validate_token(bearer)
686+
except Exception:
687+
logger.exception("token validator raised on A2A request")
688+
return None
689+
690+
if inspect.isawaitable(raw):
691+
# Should be unreachable — :func:`_assert_sync_validator` at
692+
# config time rejects async validators before any traffic
693+
# lands. This branch is the in-depth catch in case an
694+
# adopter swaps in an async validator at runtime via a
695+
# closure that conditionally awaits.
696+
logger.error(
697+
"a2a auth rejected: validator returned awaitable at request "
698+
"time. Async validators are not supported on the A2A leg; "
699+
"wrap with a sync bridge."
700+
)
701+
return None
702+
703+
if raw is None:
704+
logger.info("a2a auth rejected", extra={"reason": "invalid_token"})
705+
return None
706+
return raw
707+
708+
async def _send_unauthenticated(self, send: Any) -> None:
709+
body_obj = self._config.unauthenticated_response or {
710+
"error": "invalid_token",
711+
"error_description": "Bearer token missing or invalid",
712+
}
713+
body = json.dumps(body_obj).encode("utf-8")
714+
# RFC 6750 §3 + RFC 7235 §3.1 require ``WWW-Authenticate: Bearer``
715+
# on every 401. Without it, RFC-compliant clients (including
716+
# browsers and many HTTP libraries) won't surface the auth
717+
# challenge to the user — they treat the 401 as a generic
718+
# error. Always emit; even when the operator overrides
719+
# ``unauthenticated_response``, the header stays for protocol
720+
# compliance.
721+
await send(
722+
{
723+
"type": "http.response.start",
724+
"status": 401,
725+
"headers": [
726+
(b"content-type", b"application/json"),
727+
(b"content-length", str(len(body)).encode("latin-1")),
728+
(b"www-authenticate", b'Bearer realm="a2a", error="invalid_token"'),
729+
],
730+
}
731+
)
732+
await send({"type": "http.response.body", "body": body})
733+
734+
735+
@dataclass(frozen=True)
736+
class _A2AAuthenticatedUser:
737+
"""Minimal Starlette-BaseUser-shaped object for :class:`StarletteUser`.
738+
739+
a2a-sdk's :class:`StarletteUser` adapter wants ``is_authenticated``
740+
(bool) and ``display_name`` (str). It doesn't import Starlette's
741+
:class:`BaseUser` directly — duck-typing works. We synthesize a
742+
frozen dataclass so the principal's identity flows through with no
743+
Starlette dependency on the auth side.
744+
"""
745+
746+
display_name: str
747+
tenant_id: str | None = None
748+
principal_metadata: dict[str, Any] | None = None
749+
750+
@property
751+
def is_authenticated(self) -> bool:
752+
return True

0 commit comments

Comments
 (0)