@@ -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