Module adcp.server.signed_requests
Framework RFC 9421 request-signature verification, run before dispatch.
A seller that declares request_signing in get_adcp_capabilities opts
in by passing :class:RequestSignatureVerification to
:func:serve() (serve() builds one from the
platform's declared capabilities when given signer_keys=). The framework
then, for every JSON-RPC POST on the MCP and A2A legs:
- rejects an unsigned request to an operation in
required_for(or a JSON-RPC method inprotocol_methods_required_for) with401andWWW-Authenticate: Signature error="request_signature_required"; - verifies any presented signature against the key a
:class:
~adcp.signing.SignerKeyResolvermaps thekeyidto, rejecting a failure with the spec error code (warn_foroperations log and continue without identity instead, except for a malformed signature pair); - on success, populates
ToolContext.caller_identitywith the signer's agent URL andmetadata["adcp.auth_info"]with :meth:AuthInfo.from_verified_signer <adcp.decisioning.AuthInfo.from_verified_signer>, so :class:~adcp.decisioning.BuyerAgentRegistrydispatch resolves the buyer agent with no seller glue.
Verification runs at the ASGI edge because it needs the raw body bytes; the
result travels to dispatch on scope["state"] (which survives the stateful
MCP session task, like bearer auth's principal) and a ContextVar.
Sellers who already verify with :func:verify_starlette_request()
in their own asgi_middleware keep working: that helper records its result
on the request scope, and the framework reuses it instead of verifying again
— so the nonce is claimed exactly once.
Global variables
var current_verified_signer : _contextvars.ContextVar[VerifiedSigner | None]-
Verified signer for the in-flight request. Carries on stateless MCP and A2A, where dispatch shares the middleware's task; stateful MCP reads the
scope["state"]mirror instead.
Functions
def apply_verified_signer(context: ToolContext | None, request_context: Any) ‑> ToolContext | None-
Expand source code
def apply_verified_signer( context: ToolContext | None, request_context: Any, ) -> ToolContext | None: """Overlay the verified signer's identity onto a dispatch ``ToolContext``. Returns a copy with ``metadata["adcp.auth_info"]`` set to an ``http_sig`` :class:`AuthInfo` and ``caller_identity`` set to the signer's agent URL, so both identity surfaces name the same principal (a bearer principal presented alongside the signature is superseded). A context whose factory already supplied a typed credential is returned unchanged — the factory owns identity there. Also unchanged when the request carried no framework-verified signature. """ signer = verified_signer_for_request(request_context) if signer is None or signer.agent_url is None: return context from adcp.decisioning.context import AuthInfo from adcp.server.base import ToolContext if context is None: context = ToolContext() else: existing = context.metadata.get("adcp.auth_info") if existing is not None and getattr(existing, "credential", None) is not None: return context # Copy so a factory that returns a shared instance never carries one # request's signer into another. context = copy.copy(context) context.metadata = dict(context.metadata) context.metadata["adcp.auth_info"] = AuthInfo.from_verified_signer(signer) context.caller_identity = signer.agent_url return contextOverlay the verified signer's identity onto a dispatch
ToolContext.Returns a copy with
metadata["adcp.auth_info"]set to anhttp_sig:class:AuthInfoandcaller_identityset to the signer's agent URL, so both identity surfaces name the same principal (a bearer principal presented alongside the signature is superseded). A context whose factory already supplied a typed credential is returned unchanged — the factory owns identity there. Also unchanged when the request carried no framework-verified signature. def verified_signer_for_request(request_context: Any) ‑> VerifiedSigner | None-
Expand source code
def verified_signer_for_request(request_context: Any) -> VerifiedSigner | None: """Return the framework-verified signer for a dispatching request, if any. When the originating HTTP request is available its scope is the only source consulted. The ContextVar is a fallback for callers without one: a long-lived task (such as a stateful MCP session) can carry the context of the request that created it, so trusting the ContextVar there could attribute that request's signer to later requests. Only framework verification writes ``scope["state"]``; the top-level scope key a seller's own ``verify_starlette_request`` sets is a reuse handshake and does not change identity for sellers who haven't opted in. """ scope = getattr(request_context, "scope", None) if isinstance(scope, Mapping): state = scope.get("state") signer = state.get(VERIFIED_SIGNER_SCOPE_KEY) if isinstance(state, Mapping) else None return signer if isinstance(signer, VerifiedSigner) else None return current_verified_signer.get()Return the framework-verified signer for a dispatching request, if any.
When the originating HTTP request is available its scope is the only source consulted. The ContextVar is a fallback for callers without one: a long-lived task (such as a stateful MCP session) can carry the context of the request that created it, so trusting the ContextVar there could attribute that request's signer to later requests.
Only framework verification writes
scope["state"]; the top-level scope key a seller's ownverify_starlette_requestsets is a reuse handshake and does not change identity for sellers who haven't opted in.
Classes
class RequestSignatureVerification (*,
signer_keys: SignerKeyResolver,
required_for: frozenset[str] = frozenset(),
warn_for: frozenset[str] = frozenset(),
supported_for: frozenset[str] = frozenset(),
protocol_methods_required_for: frozenset[str] = frozenset(),
protocol_methods_warn_for: frozenset[str] = frozenset(),
covers_content_digest: CoversDigestPolicy = 'either',
allow_bearer_fallback: bool = False,
replay_store: ReplayStore | None = <factory>,
revocation_checker: RevocationChecker | None = None,
signing_profile_version: SigningProfileVersion = '3.1')-
Expand source code
@dataclass(frozen=True, kw_only=True) class RequestSignatureVerification: """Configuration for framework request-signature verification. Build from a declared capability block with :meth:`from_capability` so the enforced policy cannot drift from what ``get_adcp_capabilities`` advertises:: from adcp.server import RequestSignatureVerification, serve from adcp.signing import StaticSignerKeys serve( handler, request_signature_verification=RequestSignatureVerification.from_capability( capabilities.request_signing, signer_keys=StaticSignerKeys({"https://buyer.example": buyer_jwks}), replay_store=pg_replay_store, ), ) :param signer_keys: Maps a ``keyid`` to the owning agent URL and JWK. :param required_for: AdCP operation names that reject unsigned requests. :param warn_for: Operation names in shadow mode — missing or failed signatures are logged, and the request continues without identity. :param supported_for: Operation names that verify when signed. Advisory; a presented signature on any operation outside ``warn_for`` is verified and must pass. :param protocol_methods_required_for: JSON-RPC ``method`` names (exact, case-sensitive, e.g. ``tasks/cancel``) that reject unsigned requests. :param protocol_methods_warn_for: JSON-RPC methods in shadow mode. :param covers_content_digest: Enforced digest-coverage policy. Declare ``"required"`` for AdCP 3.2. :param allow_bearer_fallback: Accept an unsigned request to a required operation when bearer auth (``serve(auth=...)``) authenticates it — the spec's "independently valid configured fallback authenticator". The bearer must then be presented and valid: discovery and network-trust bypasses do not apply, and a failure is a ``401`` carrying both the ``Signature`` and ``Bearer`` challenges. Requires ``auth=``. Default ``False`` rejects every unsigned required request. :param replay_store: Nonce store shared by every request. The default is one per-process :class:`~adcp.signing.InMemoryReplayStore`, which does not protect a multi-replica deployment; wire a shared store (e.g. ``PgReplayStore``) there. ``None`` disables replay checks. """ signer_keys: SignerKeyResolver required_for: frozenset[str] = frozenset() warn_for: frozenset[str] = frozenset() supported_for: frozenset[str] = frozenset() protocol_methods_required_for: frozenset[str] = frozenset() protocol_methods_warn_for: frozenset[str] = frozenset() covers_content_digest: CoversDigestPolicy = "either" allow_bearer_fallback: bool = False replay_store: ReplayStore | None = field(default_factory=InMemoryReplayStore) revocation_checker: RevocationChecker | None = None signing_profile_version: SigningProfileVersion = "3.1" def __post_init__(self) -> None: overlap = self.required_for & self.warn_for if overlap: raise ValueError( f"operations {sorted(overlap)} appear in both required_for and warn_for" ) method_overlap = self.protocol_methods_required_for & self.protocol_methods_warn_for if method_overlap: raise ValueError( f"methods {sorted(method_overlap)} appear in both " "protocol_methods_required_for and protocol_methods_warn_for" ) if "tools/call" in (self.protocol_methods_required_for | self.protocol_methods_warn_for): raise ValueError( "'tools/call' cannot be listed as a protocol method; list the AdCP " "operation (its params.name) in required_for / warn_for instead" ) @classmethod def from_capability( cls, capability: Any, *, signer_keys: SignerKeyResolver, **overrides: Any, ) -> RequestSignatureVerification: """Build from a ``request_signing`` capability block. Accepts the generated ``RequestSigning`` model or its dict form. ``overrides`` set the fields the capability does not carry (``replay_store``, ``revocation_checker``, ``signing_profile_version``, ``allow_bearer_fallback``). """ if hasattr(capability, "model_dump"): capability = capability.model_dump(mode="json", exclude_none=True) if not isinstance(capability, Mapping): raise TypeError( "capability must be a RequestSigning model or mapping, got " f"{type(capability).__name__}" ) def names(key: str) -> frozenset[str]: return frozenset(str(item) for item in capability.get(key) or ()) kwargs: dict[str, Any] = { "required_for": names("required_for"), "warn_for": names("warn_for"), "supported_for": names("supported_for"), "protocol_methods_required_for": names("protocol_methods_required_for"), "protocol_methods_warn_for": names("protocol_methods_warn_for"), } digest = capability.get("covers_content_digest") if digest is not None: kwargs["covers_content_digest"] = digest kwargs.update(overrides) return cls(signer_keys=signer_keys, **kwargs) def posture_for( self, method: str | None, operation: str | None, *, opaque: bool = False ) -> SignaturePosture | None: """Signing posture for a JSON-RPC ``method`` / AdCP ``operation``. Precedence is ``required`` > ``warn`` > ``supported`` across both namespaces. An ``opaque`` request — batch, unparseable body, or an A2A message whose skill cannot be determined — is ``required`` whenever anything is required, so a mutation cannot slip past enforcement inside an envelope the verifier cannot read. """ if opaque: if self.required_for or self.protocol_methods_required_for: return "required" return None if operation in self.required_for or method in self.protocol_methods_required_for: return "required" if operation in self.warn_for or method in self.protocol_methods_warn_for: return "warn" if operation in self.supported_for: return "supported" return NoneConfiguration for framework request-signature verification.
Build from a declared capability block with :meth:
from_capabilityso the enforced policy cannot drift from whatget_adcp_capabilitiesadvertises::from adcp.server import RequestSignatureVerification, serve from adcp.signing import StaticSignerKeys serve( handler, request_signature_verification=RequestSignatureVerification.from_capability( capabilities.request_signing, signer_keys=StaticSignerKeys({"https://buyer.example": buyer_jwks}), replay_store=pg_replay_store, ), ):param signer_keys: Maps a
keyidto the owning agent URL and JWK. :param required_for: AdCP operation names that reject unsigned requests. :param warn_for: Operation names in shadow mode — missing or failed signatures are logged, and the request continues without identity. :param supported_for: Operation names that verify when signed. Advisory; a presented signature on any operation outsidewarn_foris verified and must pass. :param protocol_methods_required_for: JSON-RPCmethodnames (exact, case-sensitive, e.g.tasks/cancel) that reject unsigned requests. :param protocol_methods_warn_for: JSON-RPC methods in shadow mode. :param covers_content_digest: Enforced digest-coverage policy. Declare"required"for AdCP 3.2. :param allow_bearer_fallback: Accept an unsigned request to a required operation when bearer auth (serve(auth=...)) authenticates it — the spec's "independently valid configured fallback authenticator". The bearer must then be presented and valid: discovery and network-trust bypasses do not apply, and a failure is a401carrying both theSignatureandBearerchallenges. Requiresauth=. DefaultFalserejects every unsigned required request. :param replay_store: Nonce store shared by every request. The default is one per-process :class:~adcp.signing.InMemoryReplayStore, which does not protect a multi-replica deployment; wire a shared store (e.g.PgReplayStore) there.Nonedisables replay checks.Static methods
def from_capability(capability: Any, *, signer_keys: SignerKeyResolver, **overrides: Any)-
Build from a
request_signingcapability block.Accepts the generated
RequestSigningmodel or its dict form.overridesset the fields the capability does not carry (replay_store,revocation_checker,signing_profile_version,allow_bearer_fallback).
Instance variables
var allow_bearer_fallback : boolvar covers_content_digest : CoversDigestPolicyvar protocol_methods_required_for : frozenset[str]var protocol_methods_warn_for : frozenset[str]var replay_store : ReplayStore | Nonevar required_for : frozenset[str]var revocation_checker : RevocationChecker | Nonevar signer_keys : SignerKeyResolvervar signing_profile_version : SigningProfileVersionvar supported_for : frozenset[str]var warn_for : frozenset[str]
Methods
def posture_for(self, method: str | None, operation: str | None, *, opaque: bool = False) ‑> Literal['required', 'warn', 'supported'] | None-
Expand source code
def posture_for( self, method: str | None, operation: str | None, *, opaque: bool = False ) -> SignaturePosture | None: """Signing posture for a JSON-RPC ``method`` / AdCP ``operation``. Precedence is ``required`` > ``warn`` > ``supported`` across both namespaces. An ``opaque`` request — batch, unparseable body, or an A2A message whose skill cannot be determined — is ``required`` whenever anything is required, so a mutation cannot slip past enforcement inside an envelope the verifier cannot read. """ if opaque: if self.required_for or self.protocol_methods_required_for: return "required" return None if operation in self.required_for or method in self.protocol_methods_required_for: return "required" if operation in self.warn_for or method in self.protocol_methods_warn_for: return "warn" if operation in self.supported_for: return "supported" return NoneSigning posture for a JSON-RPC
method/ AdCPoperation.Precedence is
required>warn>supportedacross both namespaces. Anopaquerequest — batch, unparseable body, or an A2A message whose skill cannot be determined — isrequiredwhenever anything is required, so a mutation cannot slip past enforcement inside an envelope the verifier cannot read.
class SignedRequestVerificationMiddleware (app: Any,
config: RequestSignatureVerification,
*,
transport: "Literal['mcp', 'a2a']",
message_parser: Any = None,
bearer_configured: bool = False)-
Expand source code
class SignedRequestVerificationMiddleware: """Pure-ASGI middleware enforcing :class:`RequestSignatureVerification`.""" def __init__( self, app: Any, config: RequestSignatureVerification, *, transport: Literal["mcp", "a2a"], message_parser: Any = None, bearer_configured: bool = False, ) -> None: self._app = app self._config = config self._transport = transport self._message_parser = message_parser self._parse_skill = bool(config.required_for or config.warn_for) self._bearer_configured = bearer_configured async def __call__(self, scope: Any, receive: Any, send: Any) -> None: # Only JSON-RPC POSTs carry an operation; GET (SSE, agent card), # DELETE (session end), and OPTIONS (CORS preflight) pass through. if scope.get("type") != "http" or scope.get("method") != "POST": await self._app(scope, receive, send) return existing = scope.get(VERIFIED_SIGNER_SCOPE_KEY) if isinstance(existing, VerifiedSigner): # A seller's own verify_starlette_request already claimed the # nonce, so re-verifying would reject the request as replayed. # Its identity is trusted only if the seller named the agent; # the framework's resolver never vouches for a key it did not # check. if existing.agent_url is not None: await self._call_with_signer(scope, receive, send, existing) else: await self._app(scope, receive, send) return chunks: list[bytes] = [] more_body = True while more_body: message = await receive() if message.get("type") == "http.disconnect": return if message.get("type") != "http.request": continue chunks.append(message.get("body", b"")) more_body = bool(message.get("more_body", False)) body = b"".join(chunks) replay = make_replay_receive(chunks) target = _jsonrpc_target( body, self._transport, self._message_parser, parse_skill=self._parse_skill ) if target.a2a_parsed is not None: from adcp.server.a2a_server import _A2A_PARSED_REQUEST_SCOPE_KEY scope[_A2A_PARSED_REQUEST_SCOPE_KEY] = target.a2a_parsed posture = self._config.posture_for(target.method, target.operation, opaque=target.opaque) label = target.operation or target.method or "" signature_input = _header(scope, b"signature-input") signature = _header(scope, b"signature") if signature_input is None and signature is None: if posture == "required": if self._config.allow_bearer_fallback and self._bearer_configured: scope[_SIGNATURE_FALLBACK_SCOPE_KEY] = REQUEST_SIGNATURE_REQUIRED await self._app(scope, replay, send) return await self._reject( send, SignatureVerificationError( REQUEST_SIGNATURE_REQUIRED, step=0, message=f"operation {label!r} requires a signature", ), ) return if posture == "warn": logger.warning( "unsigned request to warn_for target", extra={"adcp_operation": label, "reason": REQUEST_SIGNATURE_REQUIRED}, ) await self._app(scope, replay, send) return try: signer = await self._verify(scope, body, label, posture) except SignatureVerificationError as exc: if posture == "warn" and exc.code != REQUEST_SIGNATURE_HEADER_MALFORMED: logger.warning( "request signature failed on warn_for target", extra={"adcp_operation": label, "reason": exc.code, "step": exc.step}, ) await self._app(scope, replay, send) return await self._reject(send, exc) return await self._call_with_signer(scope, replay, send, signer) async def _verify( self, scope: Any, body: bytes, label: str, posture: SignaturePosture | None, ) -> VerifiedSigner: resolved: dict[str, ResolvedSignerKey] = {} # The verifier looks the key up at its own checklist step 7, after # the cheap header, parameter, and window checks, so attacker-chosen # keyids never reach the resolver on a malformed request. It runs in # a worker thread (replay stores may do blocking I/O), so the async # resolver is bridged back onto the event loop. def jwks_resolver(keyid: str) -> dict[str, Any] | None: key = anyio.from_thread.run(self._resolve, keyid) if key is None: return None resolved[keyid] = key return dict(key.jwk) config = self._config request = Request(scope) options = VerifyOptions( now=time.time(), capability=VerifierCapability( covers_content_digest=config.covers_content_digest, required_for=config.required_for, supported_for=config.supported_for, ), operation=label, jwks_resolver=jwks_resolver, replay_store=config.replay_store, revocation_checker=config.revocation_checker, signing_profile_version=config.signing_profile_version, posture=posture, ) signer = await anyio.to_thread.run_sync( partial( verify_request_signature, method=request.method, url=str(request.url), headers=dict(request.headers), body=body, options=options, raw_headers=scope.get("headers"), ) ) key = resolved.get(signer.key_id) return replace(signer, agent_url=key.agent_url if key is not None else None) async def _resolve(self, keyid: str) -> ResolvedSignerKey | None: try: return await self._config.signer_keys(keyid) except SignatureVerificationError: raise except Exception as exc: # A resolver bug or outage must not become a 500 that leaks # internals; it is a key-fetch failure from the buyer's view. logger.exception("signer key resolver raised") raise SignatureVerificationError( REQUEST_SIGNATURE_JWKS_UNAVAILABLE, step=7, message="signer key resolution failed", ) from exc async def _call_with_signer( self, scope: Any, receive: Any, send: Any, signer: VerifiedSigner ) -> None: scope[VERIFIED_SIGNER_SCOPE_KEY] = signer scope[_FRAMEWORK_VERIFIED_SCOPE_KEY] = True scope.setdefault("state", {})[VERIFIED_SIGNER_SCOPE_KEY] = signer if ( self._transport == "a2a" and signer.agent_url is not None and not getattr(scope.get("user"), "is_authenticated", False) ): # a2a-sdk scopes task ownership by the authenticated user. Without # this every signed buyer shares one owner and can read or cancel # another buyer's tasks. Bearer auth (inner) overrides it when a # token is also presented. from adcp.server.auth import _A2AAuthenticatedUser scope["user"] = _A2AAuthenticatedUser(display_name=signer.agent_url) token = current_verified_signer.set(signer) try: await self._app(scope, receive, send) finally: current_verified_signer.reset(token) @staticmethod async def _reject(send: Any, exc: SignatureVerificationError) -> None: logger.info( "request signature rejected", extra={"reason": exc.code, "step": exc.step}, ) response = JSONResponse( {"error": exc.code}, status_code=401, headers=unauthorized_response_headers(exc), ) async def no_receive() -> dict[str, Any]: return {"type": "http.disconnect"} await response({"type": "http"}, no_receive, send)Pure-ASGI middleware enforcing :class:
RequestSignatureVerification.