Skip to content

Commit 52f45ef

Browse files
bokelleyclaude
andauthored
feat(auth): per-leg header config + agent-card bearerAuth scheme (#595)
Add per-leg header knobs to BearerTokenAuth and auto-publish a matching security scheme on the A2A agent card so a2a-sdk's client interceptor attaches credentials without seller-side intervention. Defaults preserve pre-#57 single-knob behavior (Authorization + Bearer prefix on both legs — RFC 6750, the protocol-canonical carrier). The mcp_/a2a_ knobs are an opt-in escape hatch for adopters with legacy clients that send a raw token in a custom header (e.g. x-adcp-auth); the spec is moving toward Authorization: Bearer canonical for both transports. Closes salesagent#57 items 1 and 2; item 3 is a separate spec-repo PR. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1db3ce3 commit 52f45ef

4 files changed

Lines changed: 670 additions & 25 deletions

File tree

src/adcp/server/a2a_server.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
)
6060
from a2a.server.tasks.task_store import TaskStore
6161

62+
from adcp.server.auth import BearerTokenAuth
6263
from adcp.server.serve import ContextFactory, SkillMiddleware
6364

6465
from collections.abc import Callable # noqa: E402
@@ -587,6 +588,75 @@ def _make_task(
587588
# ------------------------------------------------------------------
588589

589590

591+
_BEARER_HTTP_SCHEME_ID = "bearerAuth"
592+
_API_KEY_SCHEME_ID = "adcpAuth"
593+
594+
595+
def _build_security_for_auth(
596+
auth: BearerTokenAuth | None,
597+
) -> tuple[dict[str, pb.SecurityScheme], list[pb.SecurityRequirement]]:
598+
"""Translate a :class:`BearerTokenAuth` config into A2A agent-card
599+
security primitives.
600+
601+
a2a-sdk's client auth interceptor (``a2a.client.auth.interceptor``)
602+
skips credential attachment when the agent card publishes neither
603+
``security_schemes`` nor ``security_requirements`` — buyers built on
604+
a2a-sdk silently send unauthenticated requests against an
605+
auth-protected seller and see a 401 they have no obvious way to fix.
606+
Publishing a scheme that matches the A2A leg's actual carrier closes
607+
that loop without requiring the seller to hand-roll an agent card.
608+
609+
Returns ``({}, [])`` when ``auth`` is ``None`` so unauthenticated
610+
agents continue to publish no security envelope (preserving the
611+
pre-auth public-discovery shape).
612+
613+
Maps the resolved A2A header / prefix config to the right OpenAPI-
614+
flavored scheme. The cut is on ``bearer_prefix_required`` alone, not
615+
on header name — RFC 7235 reserves ``Authorization`` for
616+
``<scheme> <credentials>`` and ``__post_init__`` already rejects the
617+
misuse combo of ``Authorization`` + ``bearer_prefix_required=False``:
618+
619+
* Bearer prefix required → :class:`HTTPAuthSecurityScheme` with
620+
``scheme="bearer"`` (RFC 6750, scheme id ``"bearerAuth"``). This
621+
is what a2a-sdk's interceptor knows how to attach credentials for
622+
out of the box.
623+
* No bearer prefix (raw-token custom header) →
624+
:class:`APIKeySecurityScheme` (``in: header``, scheme id
625+
``"adcpAuth"``). Buyers reading the card see the right shape and
626+
know to attach a raw token to the named header.
627+
628+
The single :class:`SecurityRequirement` references the scheme by id
629+
with no scope list — bearer / api-key flows have no scope semantics
630+
in OpenAPI 3.x. ``bearer_format`` is intentionally omitted: tokens
631+
are validator-defined and the field is purely descriptive (a2a-sdk's
632+
interceptor doesn't read it).
633+
"""
634+
if auth is None:
635+
return {}, []
636+
637+
header_name = auth.resolved_a2a_header_name()
638+
bearer_prefix = auth.resolved_a2a_bearer_prefix_required()
639+
640+
if bearer_prefix:
641+
scheme_id = _BEARER_HTTP_SCHEME_ID
642+
scheme = pb.SecurityScheme(
643+
http_auth_security_scheme=pb.HTTPAuthSecurityScheme(scheme="bearer"),
644+
)
645+
else:
646+
scheme_id = _API_KEY_SCHEME_ID
647+
scheme = pb.SecurityScheme(
648+
api_key_security_scheme=pb.APIKeySecurityScheme(
649+
location="header",
650+
name=header_name,
651+
),
652+
)
653+
654+
requirement = pb.SecurityRequirement(
655+
schemes={scheme_id: pb.StringList(list=[])},
656+
)
657+
return {scheme_id: scheme}, [requirement]
658+
659+
590660
def _build_agent_card(
591661
handler: ADCPHandler[Any],
592662
*,
@@ -597,6 +667,7 @@ def _build_agent_card(
597667
extra_skills: list[pb.AgentSkill] | None = None,
598668
advertise_all: bool = False,
599669
push_notifications_supported: bool = False,
670+
auth: BearerTokenAuth | None = None,
600671
) -> pb.AgentCard:
601672
"""Build an A2A AgentCard from an ADCPHandler's tool definitions.
602673
@@ -634,10 +705,14 @@ def _build_agent_card(
634705

635706
url = f"http://localhost:{port}/"
636707

708+
security_schemes, security_requirements = _build_security_for_auth(auth)
709+
637710
return pb.AgentCard(
638711
name=name,
639712
description=description or f"ADCP agent: {name}",
640713
version=version,
714+
security_schemes=security_schemes,
715+
security_requirements=security_requirements,
641716
# Ordering is load-bearing: a2a-sdk's v0.3 compat converter
642717
# (``a2a.compat.v0_3.conversions.to_compat_agent_card``) sets
643718
# ``primary_interface = compat_interfaces[0]``, so the entry it
@@ -683,6 +758,7 @@ def create_a2a_server(
683758
advertise_all: bool = False,
684759
validation: ValidationHookConfig | None = SERVER_DEFAULT_VALIDATION,
685760
context_builder: Any | None = None,
761+
auth: BearerTokenAuth | None = None,
686762
) -> Any:
687763
"""Create an A2A Starlette application from an ADCP handler.
688764
@@ -768,6 +844,16 @@ def create_a2a_server(
768844
``ValidationHookConfig(responses="warn")`` to log+continue
769845
on response drift, or ``validation=None`` to disable
770846
validation entirely.
847+
auth: Optional :class:`~adcp.server.auth.BearerTokenAuth`
848+
config. When supplied, the agent card publishes a matching
849+
``bearerAuth`` security scheme + requirement so a2a-sdk's
850+
client auth interceptor attaches credentials automatically.
851+
Note that ``create_a2a_server`` does **not** install the
852+
request-time middleware itself — auth gating is wired by
853+
:func:`adcp.server.serve` via :class:`A2ABearerAuthMiddleware`
854+
at the ASGI layer. Adopters calling ``create_a2a_server``
855+
directly must wrap the returned app with
856+
:class:`A2ABearerAuthMiddleware` themselves.
771857
772858
Returns:
773859
A Starlette app ready to be run with uvicorn.
@@ -794,6 +880,7 @@ def create_a2a_server(
794880
extra_skills=_test_controller_skills() if test_controller else None,
795881
advertise_all=advertise_all,
796882
push_notifications_supported=push_config_store is not None,
883+
auth=auth,
797884
)
798885

799886
if task_store is None:

src/adcp/server/auth.py

Lines changed: 167 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -525,9 +525,8 @@ class BearerTokenAuth:
525525
"""Cross-transport bearer-token auth config for :func:`adcp.server.serve`.
526526
527527
Single source of truth that wires the same ``validate_token``
528-
callback (and ``header_name`` / ``bearer_prefix_required`` knobs)
529-
into both the MCP-side :class:`BearerTokenAuthMiddleware` and the
530-
A2A-side :class:`BearerTokenContextBuilder`. Pass via
528+
callback into both the MCP-side :class:`BearerTokenAuthMiddleware`
529+
and the A2A-side :class:`A2ABearerAuthMiddleware`. Pass via
531530
``serve(auth=BearerTokenAuth(...))`` and both legs are
532531
authenticated against the same token store with no per-leg
533532
drift::
@@ -547,26 +546,176 @@ class BearerTokenAuth:
547546
548547
On MCP, requests without a valid token receive a JSON ``401``
549548
body. On A2A, requests without a valid token receive an HTTP
550-
``401`` from Starlette's :class:`HTTPException`. Discovery
551-
bypasses are transport-specific:
549+
``401``. Discovery bypasses are transport-specific:
552550
553551
* **MCP**: ``initialize`` / ``tools/list`` / ``notifications/initialized``
554552
/ ``get_adcp_capabilities`` (JSON-RPC method-level bypass).
555-
* **A2A**: ``/.well-known/agent-card.json`` (route-level — the
556-
agent-card route is created separately and never invokes the
557-
builder, so no path-based exemption is needed in the
558-
:class:`BearerTokenContextBuilder` itself).
559-
560-
Knobs mirror :class:`BearerTokenAuthMiddleware` exactly: pass
561-
``header_name="x-adcp-auth"`` and ``bearer_prefix_required=False``
562-
for non-OAuth custom-header schemes.
553+
* **A2A**: ``/.well-known/agent-card.json`` (path-based — the
554+
agent-card route is registered alongside the JSON-RPC routes
555+
and the middleware exempts the well-known path).
556+
557+
**Canonical carrier: ``Authorization: Bearer <token>`` (RFC 6750).**
558+
Both legs default to this. It is the only header backed by an actual
559+
RFC, what every off-the-shelf MCP / A2A / HTTP client emits by
560+
default, and what the AdCP spec is moving toward as canonical for
561+
both transports. Reach for ``BearerTokenAuth(validate_token=...)``
562+
with no other knobs and you get the protocol-canonical setup —
563+
including a ``bearerAuth`` ``HTTPAuthSecurityScheme`` (``scheme="bearer"``)
564+
auto-published on the agent card so a2a-sdk-based clients attach
565+
credentials without seller-side intervention.
566+
567+
**``x-adcp-auth`` is a legacy-compat alias, not a recommended
568+
default.** Some early MCP adopters baked in a custom ``x-adcp-auth``
569+
header carrying a raw token (no scheme prefix) before the spec
570+
settled. Sellers with deployed clients that can't be updated can
571+
opt in per-leg::
572+
573+
BearerTokenAuth(
574+
validate_token=...,
575+
mcp_header_name="x-adcp-auth", # legacy MCP clients only
576+
mcp_bearer_prefix_required=False,
577+
# A2A keeps the canonical RFC 6750 carrier by default
578+
)
579+
580+
Selecting a non-``Authorization`` header on the A2A leg is
581+
discouraged — buyers using non-a2a-sdk HTTP clients may not parse
582+
the resulting :class:`APIKeySecurityScheme` shape, and you lose
583+
interop with off-the-shelf A2A tooling. Use only when you control
584+
every buyer client.
585+
586+
**Legacy single-knob compatibility.** ``header_name`` and
587+
``bearer_prefix_required`` are still accepted: when set, they
588+
apply to *both* legs and override the per-leg defaults. Setting
589+
both ``header_name`` and a per-leg ``*_header_name`` (or both
590+
``bearer_prefix_required`` and a per-leg
591+
``*_bearer_prefix_required``) raises at construction — the
592+
framework can't decide which the operator intended.
563593
"""
564594

565595
validate_token: TokenValidator
566-
header_name: str = "authorization"
567-
bearer_prefix_required: bool = True
596+
# Legacy single-knob — applies to BOTH legs when set. Mutually
597+
# exclusive with the per-leg knobs below. Adopters who want the
598+
# canonical RFC 6750 setup should leave these unset (defaults
599+
# resolve to ``Authorization`` + ``Bearer`` prefix).
600+
header_name: str | None = None
601+
bearer_prefix_required: bool | None = None
602+
# Per-leg knobs — opt-in escape hatch for adopters with legacy
603+
# clients that send a raw token in a custom header (e.g.
604+
# ``x-adcp-auth``). The protocol-canonical carrier is
605+
# ``Authorization: Bearer <token>`` on both legs; reach for these
606+
# only when you can't update the client side.
607+
mcp_header_name: str | None = None
608+
mcp_bearer_prefix_required: bool | None = None
609+
a2a_header_name: str | None = None
610+
a2a_bearer_prefix_required: bool | None = None
568611
unauthenticated_response: dict[str, Any] | None = None
569612

613+
def __post_init__(self) -> None:
614+
if self.header_name is not None and (
615+
self.mcp_header_name is not None or self.a2a_header_name is not None
616+
):
617+
raise ValueError(
618+
"BearerTokenAuth: set either header_name (applies to both legs) "
619+
"or mcp_header_name / a2a_header_name (per-leg) — not both."
620+
)
621+
if self.bearer_prefix_required is not None and (
622+
self.mcp_bearer_prefix_required is not None
623+
or self.a2a_bearer_prefix_required is not None
624+
):
625+
raise ValueError(
626+
"BearerTokenAuth: set either bearer_prefix_required (applies "
627+
"to both legs) or mcp_bearer_prefix_required / "
628+
"a2a_bearer_prefix_required (per-leg) — not both."
629+
)
630+
631+
# Reject empty-string headers — they would silently 401 every
632+
# request because no wire header matches an empty name. A typo
633+
# like ``header_name=""`` should fail loudly at construction.
634+
for field_name in ("header_name", "mcp_header_name", "a2a_header_name"):
635+
value = getattr(self, field_name)
636+
if value is not None and not value.strip():
637+
raise ValueError(f"BearerTokenAuth: {field_name} must be a non-empty string.")
638+
639+
# ``Authorization`` is reserved by RFC 7235 for ``<scheme>
640+
# <credentials>``. Carrying a raw token in ``Authorization``
641+
# breaks RFC-compliant intermediaries and a2a-sdk's auth
642+
# interceptor (which treats the header as bearer-shaped). If an
643+
# adopter wants a raw token, they need a custom header name.
644+
for header_field, prefix_field, leg in (
645+
("header_name", "bearer_prefix_required", "both"),
646+
("mcp_header_name", "mcp_bearer_prefix_required", "MCP"),
647+
("a2a_header_name", "a2a_bearer_prefix_required", "A2A"),
648+
):
649+
header = getattr(self, header_field)
650+
prefix = getattr(self, prefix_field)
651+
if header is not None and header.lower() == "authorization" and prefix is False:
652+
raise ValueError(
653+
f"BearerTokenAuth: {header_field}='Authorization' with "
654+
f"{prefix_field}=False on the {leg} leg violates RFC 7235 "
655+
"(Authorization carries '<scheme> <credentials>'). Use a "
656+
"custom header name (e.g. 'x-adcp-auth') for raw-token "
657+
"schemes."
658+
)
659+
660+
def resolved_mcp_header_name(self) -> str:
661+
"""Effective MCP header name after legacy + default fallback.
662+
663+
Resolution order: legacy ``header_name`` → ``mcp_header_name``
664+
→ ``"authorization"`` (RFC 6750, the protocol-canonical carrier
665+
on MCP). Adopters with legacy clients sending ``x-adcp-auth``
666+
opt in via ``mcp_header_name``; the default itself stays on
667+
``Authorization`` because that's what the spec is moving
668+
toward as canonical.
669+
"""
670+
if self.header_name is not None:
671+
return self.header_name
672+
if self.mcp_header_name is not None:
673+
return self.mcp_header_name
674+
return "authorization"
675+
676+
def resolved_mcp_bearer_prefix_required(self) -> bool:
677+
"""Effective MCP bearer-prefix flag after legacy + default fallback.
678+
679+
Resolution order: legacy ``bearer_prefix_required`` →
680+
``mcp_bearer_prefix_required`` → ``True`` (RFC 6750 — the
681+
canonical setup is ``Authorization: Bearer <token>``).
682+
"""
683+
if self.bearer_prefix_required is not None:
684+
return self.bearer_prefix_required
685+
if self.mcp_bearer_prefix_required is not None:
686+
return self.mcp_bearer_prefix_required
687+
return True
688+
689+
def resolved_a2a_header_name(self) -> str:
690+
"""Effective A2A header name after legacy + default fallback.
691+
692+
Resolution order: legacy ``header_name`` → ``a2a_header_name``
693+
→ ``"Authorization"`` (RFC 6750 — what a2a-sdk and every
694+
off-the-shelf HTTP library send by default). Setting
695+
``a2a_header_name`` to anything else is discouraged: buyers
696+
using non-a2a-sdk HTTP clients may not parse the resulting
697+
:class:`APIKeySecurityScheme` shape on the agent card and
698+
you lose interop with off-the-shelf A2A tooling.
699+
"""
700+
if self.header_name is not None:
701+
return self.header_name
702+
if self.a2a_header_name is not None:
703+
return self.a2a_header_name
704+
return "Authorization"
705+
706+
def resolved_a2a_bearer_prefix_required(self) -> bool:
707+
"""Effective A2A bearer-prefix flag after legacy + default fallback.
708+
709+
Resolution order: legacy ``bearer_prefix_required`` →
710+
``a2a_bearer_prefix_required`` → ``True`` (RFC 6750 — the
711+
canonical setup is ``Authorization: Bearer <token>``).
712+
"""
713+
if self.bearer_prefix_required is not None:
714+
return self.bearer_prefix_required
715+
if self.a2a_bearer_prefix_required is not None:
716+
return self.a2a_bearer_prefix_required
717+
return True
718+
570719

571720
# ---------------------------------------------------------------------------
572721
# A2A: ASGI middleware that gates JSON-RPC requests, exempts agent-card
@@ -640,7 +789,8 @@ class A2ABearerAuthMiddleware:
640789
def __init__(self, app: Any, config: BearerTokenAuth) -> None:
641790
self._app = app
642791
self._config = config
643-
self._header_name = config.header_name.lower()
792+
self._header_name = config.resolved_a2a_header_name().lower()
793+
self._bearer_prefix_required = config.resolved_a2a_bearer_prefix_required()
644794

645795
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
646796
# Lifespan + websocket pass through unchanged. Auth applies to
@@ -725,7 +875,7 @@ def _authenticate_scope(self, scope: Any) -> Principal | None:
725875
logger.info("a2a auth rejected", extra={"reason": "header_decode"})
726876
return None
727877

728-
if self._config.bearer_prefix_required:
878+
if self._bearer_prefix_required:
729879
bearer = _parse_bearer_header(raw_header)
730880
else:
731881
stripped = raw_header.strip()

0 commit comments

Comments
 (0)