Skip to content

BearerTokenAuthMiddleware._unauthenticated omits WWW-Authenticate header (RFC 6750 §3) #712

Description

@bokelley

Summary

BearerTokenAuthMiddleware._unauthenticated() in adcp/server/auth.py:411-412 returns a 401 response without a WWW-Authenticate header, violating RFC 6750 §3 / RFC 7235 §3.1. Every adopter using adcp.server.serve() with bearer-token auth on the MCP leg has this gap; compliance suites that assert on the header — including the security_baseline/probe_unauth storyboard from @adcp/sdk comply() — fail against any such deployment.

The sibling A2ABearerAuthMiddleware._send_unauthenticated() in the same file (line 1024) emits the header correctly:

(b"www-authenticate", b'Bearer realm="a2a", error="invalid_token"'),

So the reference implementation already exists in the same module. The MCP-leg BaseHTTPMiddleware.dispatch() path just diverged.

Current code (5.3.0)

def _unauthenticated(self) -> JSONResponse:
    return JSONResponse(self._unauth_body, status_code=401)

Called from three sites covering the missing-token, validator-raised, and validator-returned-None paths (lines 352, 366, 369).

Why this matters

  • RFC compliance: Per RFC 6750 §3, every 401 from a Bearer-protected resource MUST include a WWW-Authenticate: Bearer ... challenge. RFC-compliant clients (browsers, many HTTP libraries) don't surface auth failures to the user without it.
  • AdCP compliance: The security_baseline universal storyboard's probe_unauth step asserts on header presence at the buyer-protocol layer. The storyboard accepts any 401-with-header response and fails 401-without-header.
  • Buyer-agent self-correction: LLM-driven buyer agents that walk WWW-Authenticate to choose the auth scheme silently drop the response when the header is absent and retry indefinitely against the same endpoint.

Reproduction

A 7.1.0 comply() run against https://wonderstruck.sales-agent.scope3.com/mcp (adcp 5.3.0 deployed) surfaces:

storyboard: security_baseline
step:       probe_unauth
task:       $test_kit.auth.probe_task
expected:   401 with WWW-Authenticate: Bearer realm="..."
actual:     401 with body {"error":"unauthenticated"}, no WWW-Authenticate
validation: on_401_require_header — failed

Direct repro with curl (any agent using BearerTokenAuthMiddleware):

$ curl -i -X POST https://your-agent/mcp \
    -H 'Content-Type: application/json' \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_media_buy","arguments":{}}}'

HTTP/2 401
content-type: application/json
# ← no WWW-Authenticate

{"error":"unauthenticated"}

Proposed fix

Add a WWW-Authenticate header to the JSONResponse returned by _unauthenticated(). Match the A2A-leg shape so the two paths stay symmetric:

def _unauthenticated(self) -> JSONResponse:
    return JSONResponse(
        self._unauth_body,
        status_code=401,
        headers={"WWW-Authenticate": 'Bearer realm="mcp", error="invalid_token"'},
    )

A realm of "mcp" mirrors the A2A leg's "a2a"; alternatively a single shared constant like "adcp" works.

Optional refinement

The challenge could surface a clearer error reason on the validator-raised path vs the missing-token / validator-returned-None paths:

  • Missing token (line 352): error="invalid_request" per RFC 6750 §3.1
  • Validator exception or null principal (lines 366 / 369): error="invalid_token"

Not required for spec compliance — both paths can ship error="invalid_token" and remain RFC 6750 §3-compliant.

Salesagent workaround

The Prebid salesagent deployment ships an ASGI middleware (WWWAuthenticateMiddleware) that wraps send and injects the header when the inner response is a 401 without one. Same approach Brian's DualCredentialAuditMiddleware uses for the dual-credential audit signal. The workaround is RFC-correct but redundant once this issue ships — would gladly drop the local middleware when the next adcp release lands the fix.

Severity

Spec-compliance / interop. Not a security issue (the 401 status alone is sufficient for credential rejection; the header is about scheme advertisement). Not a regression; the gap has existed since at least 5.1.0.

Repo

Filed against adcontextprotocol/adcp-client-python. Cross-referenced from bokelley/salesagent#383 (the local workaround PR).

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions