Skip to content

feat(auth): serve(auth=BearerTokenAuth(...)) — A2A sibling + cross-transport shortcut#566

Merged
bokelley merged 3 commits intomainfrom
bokelley/issue-558-a2a-bearer-auth
May 4, 2026
Merged

feat(auth): serve(auth=BearerTokenAuth(...)) — A2A sibling + cross-transport shortcut#566
bokelley merged 3 commits intomainfrom
bokelley/issue-558-a2a-bearer-auth

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 4, 2026

Closes #558.

The bug

`BearerTokenAuthMiddleware` only protected the MCP leg. Adopters who applied it via `serve(transport="both", asgi_middleware=[(BearerTokenAuthMiddleware, {...})])` got the A2A leg silently unauthenticated — the middleware's body-peek discovery logic (looking for `"method": "initialize"` etc.) doesn't match A2A's JSON-RPC namespace, and the path-based dispatcher made the cross-leg coverage dependent on which app the closure captured first. Salesagent shipped a workaround that left A2A open in production.

The fix

A new public API: `serve(handler, transport="both", auth=BearerTokenAuth(...))`.

`BearerTokenAuth` is a frozen dataclass — single source of truth driving both transports' auth from one config. `serve(auth=...)` is wired on `streamable-http` / `a2a` / `both` / `sse` (and warns + ignores on `stdio`, which has no HTTP layer).

from adcp.server import serve
from adcp.server.auth import BearerTokenAuth, validator_from_token_map, Principal

serve(
    handler,
    transport=\"both\",
    auth=BearerTokenAuth(
        validate_token=validator_from_token_map({
            \"secret-token\": Principal(caller_identity=\"p\", tenant_id=\"acme\"),
        }),
    ),
)

Internals

  • MCP leg: existing `BearerTokenAuthMiddleware` wired via `add_middleware` from `auth`'s knobs (`header_name`, `bearer_prefix_required`, `unauthenticated_response`, `validate_token`).
  • A2A leg: new `A2ABearerAuthMiddleware` (pure ASGI, not BaseHTTPMiddleware) that:
    • Path-exempts `/.well-known/agent-card.json` and the legacy `/.well-known/agent.json` alias per A2A spec §4.1
    • Validates the bearer header off raw ASGI scope
    • Short-circuits with HTTP 401 + JSON body on rejection (validator-raised exceptions are caught and projected to 401, never leak as 500)
    • Stashes a duck-typed user in `scope["user"]` so a2a-sdk's `DefaultServerCallContextBuilder` adapts the principal into `ServerCallContext.user` automatically — no custom builder needed

Why ASGI middleware (not a `ServerCallContextBuilder`)?

The bot's triage on #558 originally proposed wiring auth via `create_jsonrpc_routes(context_builder=...)`. That looked clean but failed under test: a2a-sdk's v0.3 compat adapter wraps the entire dispatch in `except Exception` and converts builder-raised `HTTPException(401)` into HTTP 200 with a JSON-RPC error body. That violates the spec-canonical HTTP 401 contract and leaks the auth path as a 200 to unauthenticated callers. ASGI-layer wrapping returns proper HTTP 401 every time.

Implementation gotcha resolved

Per the security-reviewer triage: under `transport="both"`, both legs are wrapped before the `_dispatch` closure captures sub-app references — capturing the unwrapped refs would silently bypass auth on whichever leg got wrapped second. The lifespan composer keeps unwrapped `mcp_inner` / `a2a_inner` references for `.router.lifespan_context` so startup/shutdown still composes correctly.

Tests

20 new in `tests/test_serve_auth_both.py`:

  • Unit (8): scope-level shapes, missing/invalid token → 401, validator exceptions → 401 (not 500), agent-card and legacy `agent.json` exemption, lifespan pass-through, custom header name.
  • A2A through ASGI (3): full Starlette stack — agent-card public, JSON-RPC unauth → HTTP 401 (with body `{"error": "unauthenticated"}`), valid token → handler reached.
  • transport="both" (5): the regression case. Both legs require auth, agent-card public, MCP discovery exemption preserved, no-auth default keeps the unauthenticated path.
  • Type guards (3): non-`BearerTokenAuth` → `TypeError` at boot on both legs; `auth=None` is no-op; public exports include the new symbols.

96 tests green across `test_auth_middleware.py`, `test_a2a_server.py`, `test_serve_*.py`, `test_mcp_middleware_composition.py`. Lint clean.

Test plan

  • `pytest tests/test_serve_auth_both.py` — 20 passed
  • `pytest tests/test_auth_middleware.py tests/test_a2a_server.py` — no regressions
  • `ruff check` + `mypy` — clean
  • Salesagent verifies the production fix lands as expected

🤖 Generated with Claude Code

bokelley and others added 2 commits May 4, 2026 06:52
…ansport 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>
…ot 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>
@bokelley
Copy link
Copy Markdown
Contributor Author

bokelley commented May 4, 2026

Updated based on expert review (code-reviewer + security-reviewer + ad-tech-protocol-expert):

Must-fix landed:

  • Async-validator boot rejection (_wrap_a2a_with_auth): inspect.iscoroutinefunction check at serve() time. Production deployments fail loudly instead of silently 500'ing on first traffic. MCP middleware still awaits async validators transparently.
  • WWW-Authenticate header on 401 (RFC 7235 §3.1, RFC 6750 §3): always emit Bearer realm="a2a", error="invalid_token" so spec-compliant clients surface the auth challenge.
  • RFC 6750 body shape: {"error": "invalid_token", "error_description": "..."} instead of {"error": "unauthenticated"}. Operator-supplied unauthenticated_response still wins for body, but the WWW-Authenticate header is unconditionally emitted.
  • OPTIONS preflight bypass: browser-origin buyers wouldn't have been able to do CORS preflights — the middleware would 401 the OPTIONS and the buyer never gets to retry with auth.

Should-fix landed:

  • mcp_inner = _wrap_mcp_with_auth(...) reassignment locks in the result-value pattern; future refactor that switches to fresh-callable wrappers won't silently drop auth.
  • Auth-rejection telemetry: logger.info("a2a auth rejected", extra={"reason": ...}) with a coarse code on every rejection branch (missing_header / header_decode / wrong_scheme / invalid_token). Validator exceptions still logger.exception.
  • serve(auth: BearerTokenAuth | None) typed instead of Any.

New tests: WWW-Authenticate header, RFC 6750 body shape, OPTIONS bypass, async-validator boot rejection, sync validator passes boot, validator-exception 401 survives full ASGI stack. 26 in this file + 97 elsewhere green. Lint clean.

Open question I didn't act on: ad-tech-protocol-expert wondered whether unauthenticated_response should be a (status, body, headers) triple so RFC 6750 fields can be tuned. Skipped for now — the unconditional WWW-Authenticate header keeps protocol compliance regardless of body override; if salesagent needs richer customization, it's a small follow-up.

@bokelley
Copy link
Copy Markdown
Contributor Author

bokelley commented May 4, 2026

Acknowledged. The must-fix and should-fix items all look well-reasoned, and the rationale for deferring the unauthenticated_response triple is sound — unconditional WWW-Authenticate keeps RFC 6750 compliance regardless of body customization, so the surface area stays narrow for now. No action needed from me here.


Generated by Claude Code

@bokelley
Copy link
Copy Markdown
Contributor Author

bokelley commented May 4, 2026

Got it — standing by. The unauthenticated_response deferral is noted in the review block; no further bot action on this PR unless you re-trigger via /triage execute on #558.


Generated by Claude Code

…ames

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>
@bokelley
Copy link
Copy Markdown
Contributor Author

bokelley commented May 4, 2026

Addressing reviewer feedback:

Drift-guard for _A2A_DISCOVERY_PATHS (the non-blocking concern from your verdict):

  • 1.0 path now references a2a.utils.constants.AGENT_CARD_WELL_KNOWN_PATH directly. If a2a-sdk renames it, our frozenset rebases without code changes.
  • New test_discovery_paths_match_a2a_sdk_routes walks create_agent_card_routes(agent_card=...) against a real card and asserts every registered path is in our frozenset. If a future a2a-sdk release 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 new route.
  • Legacy /.well-known/agent.json stays as a string literal (no a2a-sdk constant for it). The test is satisfied as long as the literal still matches what's actually registered, so during 0.3 sunset the legacy route just stops being registered (frozenset becomes a superset; no false fail).

On the v0.3 compat HTTPException(401) swallow — agreed it belongs in salesagent's SDK_FEEDBACK.md. The auth.py module docstring (lines 161-167 after this change) and the _wrap_a2a_with_auth docstring both call out the gotcha so future SDK readers see it. If you want me to file it as a downstream-spec issue against a2a-sdk too, let me know.

CI passing on the drift-guard commit. The PR is ready for the merge-with-caveats verdict.

@bokelley
Copy link
Copy Markdown
Contributor Author

bokelley commented May 4, 2026

Drift-guard commit reviewed — the AGENT_CARD_WELL_KNOWN_PATH constant reference and test_discovery_paths_match_a2a_sdk_routes are exactly right. One stale-docstring blocker before merge:

auth.py module docstring + BearerTokenAuth docstring still reference the abandoned design.

The module docstring (the * **A2A auth.** bullet) says:

"The A2A side is authenticated by a BearerTokenContextBuilder plumbed into a2a-sdk's create_jsonrpc_routes(context_builder=...) seam, not by a Starlette middleware — that placement bypasses the /.well-known/agent-card.json route automatically."

BearerTokenContextBuilder doesn't exist in this codebase — that was the approach rejected because the v0.3 compat adapter swallows HTTPException. The actual mechanism is A2ABearerAuthMiddleware at the ASGI layer with path-based exemption. The BearerTokenAuth docstring has the same ghost: "no path-based exemption is needed in the BearerTokenContextBuilder itself".

These two spots read as contradictions of the _wrap_a2a_with_auth docstring on the very same PR, which correctly describes the ASGI-layer approach. An adopter reading auth.py first will come away with the wrong mental model.

Quick fix — replace both BearerTokenContextBuilder references with A2ABearerAuthMiddleware and flip the "bypasses automatically via route separation" framing to "path-exempts /.well-known/agent-card.json in the middleware". The rest of the comment and test coverage is solid; green-light pending that single fixup.

On the v0.3 swallow: the in-code callouts in _wrap_a2a_with_auth and the new a2a_server.py comment are sufficient — no separate downstream issue needed unless you want the a2a-sdk maintainers' attention on the compat-adapter behavior.


Reviewed by Claude Code. Session: https://claude.ai/code/session_01X1xZipeENxR1dKLLGH2kKw


Generated by Claude Code

@bokelley bokelley merged commit 4ead607 into main May 4, 2026
16 checks passed
@bokelley
Copy link
Copy Markdown
Contributor Author

bokelley commented May 4, 2026

Fixed in 2f2a7a8. Three stale spots updated in auth.py:

  • Module docstring * **A2A auth.** bullet — replaced BearerTokenContextBuilder + create_jsonrpc_routes(context_builder=...) framing with A2ABearerAuthMiddleware at the ASGI layer with path-exemption.
  • BearerTokenAuth class docstring header — "A2A-side BearerTokenContextBuilder" → "A2A-side A2ABearerAuthMiddleware".
  • BearerTokenAuth discovery-bypass bullets — "route-level … never invokes the builder, no path-based exemption needed in BearerTokenContextBuilder" → "path-exempted in A2ABearerAuthMiddleware at the ASGI layer"; also corrected the 401-source description from HTTPException to the ASGI-layer send.

104 tests green, ruff clean.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BearerTokenAuthMiddleware × serve(transport="both") — needs A2A sibling + serve(auth=...) shortcut

1 participant