Repository navigation
Description
Measured against adcp==6.6.0.
The asymmetry
brand_authz already understands that a brand operates several agents — _find_listed_agents iterates agents[], and there is an explicit branch for "Multiple agents[] entries byte-equal the agent URL."
But the verifier's entry point accepts only one:
VerifyOptions.agent_url: str | None(adcp/signing/verifier.py:148)brand_authz.check(agent_url: str, ...)(adcp/signing/brand_authz.py:111)
So a verifier can express "I expect this request to come from this one agent," and cannot express "I expect it from any of these agents of the same counterparty."
Why that is a problem for a seller
A seller onboards a counterparty — a brand — and stores what it knows about them. When a request arrives authenticated by a bearer token, the seller wants to verify the accompanying signature against the agents that counterparty is known to operate. With a single-valued agent_url it has to pick one.
The workarounds are both poor:
- Call the verifier once per candidate URL. N JWKS resolutions, N replay-store interactions, and a refusal that has to be synthesised from N partial failures. The replay checks are the sharp edge: a nonce consumed on the first attempt may make later attempts fail for the wrong reason.
- Pass
agent_url=None. The expected-agent check is skipped entirely, which discards exactly the property being verified.
Neither is what a seller wants, and the shape of the data (agents[] is a list) says the library already knows the real cardinality.
What would help
VerifyOptions.agent_url accepting a collection — or a sibling field such as acceptable_agent_urls — with brand_authz.check matching the verified agent against any member and reporting which one matched. BrandAuthorizationResult.matched_agent_url already exists to carry that answer.
If a single expected URL is deliberate and multi-agent verification is meant to be done another way, a note in VerifyOptions' documentation saying so would be just as useful — the current asymmetry between agents[]-as-a-list and agent_url-as-a-scalar reads as an oversight rather than a decision.
Context
Downstream this forces a 1:1 principal↔agent model in a multi-tenant seller, where a brand running staging and production agents, two regional stacks, or migrating endpoints has to be represented as two separate principals — splitting one buyer's identity across credentials, accounts and media buys. Tracked at prebid/salesagent#2276.
Activity
bokelley commentedon Sep 24, 2026
Triage
Classification: Feature request
Bucket(s): signing
Status: ready-to-implement
What the experts said:
agent_urlis an SDK choice, not a spec mandate. Addingacceptable_agent_urlsis additive and non-breaking; the nonce concern is spec-compliance (wrong error code on second call), not QoL.matched_agent_urlalready exists onBrandAuthorizationResultwith no structural changes needed.VerifyOptionsisfrozen=True, kw_only=True— new optional field with default is backward-compatible. Identified two design issues: (1)VerifiedSigner.agent_urlat line 423 would beNoneif caller usesacceptable_agent_urls— fix by deriving fromgetattr(options.jwks_resolver, "agent_url", options.agent_url). (2)BrandJsonJwksResolveris single-agent-scoped by(agent_type, agent_id, brand_id)— multi-agent key lookup needs a composing resolver or the limitation must be documented. Raised mutual exclusivity check and URL canonicalization as required design decisions.My take: This is a real gap — the nonce-replay sharp edge the reporter describes is a correctness issue, not a preference. The implementation has a bounded scope but requires a second deliverable (a
MultiAgentJwksResolveror documented guidance) alongside theVerifyOptionsfield to be complete; neither part is ambiguous.Implementation brief:
src/adcp/signing/verifier.py: Addacceptable_agent_urls: frozenset[str] | None = NonetoVerifyOptions. Add__post_init__(or a validator) raisingValueErrorif bothagent_urlandacceptable_agent_urlsare set. After the JWKS resolution step (~line 291), before the replay claim (~line 402), extractresolved_url = getattr(options.jwks_resolver, "agent_url", options.agent_url)and ifacceptable_agent_urlsis set, raiseSignatureVerificationError(REQUEST_SIGNATURE_KEY_UNKNOWN)whenresolved_urlis not in the set. At line 423, updateVerifiedSignerconstruction to useresolved_url. URL-canonicalize set members at construction time using the same_canonicalize_urllogic inbrand_jwks.pyto avoid trailing-slash/default-port mismatches againstbrand.json'sagents[].url.src/adcp/signing/brand_jwks.pyor newsrc/adcp/signing/multi_agent_jwks.py: IntroduceMultiAgentBrandJsonJwksResolverthat wraps NBrandJsonJwksResolverinstances (one per agent in the set) and routes__call__(kid)to whichever resolver returns a non-None JWK for thatkid. Expose aagent_urlproperty returning the URL of the last resolver that matched. This is the missing piece that lets the feature deliver the nonce-single-call guarantee the issue requires.tests/test_brand_authz.py/tests/conformance/signing/test_verifier_behaviors.py: Add cases: (a) signer URL inacceptable_agent_urlspasses; (b) signer URL not in set raisesREQUEST_SIGNATURE_KEY_UNKNOWN; (c) setting bothagent_urlandacceptable_agent_urlsraisesValueError; (d) URL canonicalization matches e.g.https://agent.example.com/andhttps://agent.example.comas the same entry.Non-breaking rationale:
VerifyOptionsis kw_only; existing callers constructing it withoutacceptable_agent_urlsget the existing single-URL or no-check behavior unchanged.BrandJsonJwksResolver.check()signature is unchanged. The newMultiAgentBrandJsonJwksResolveris additive.Required checks:
make lint && make typecheck-all && pytest tests/test_brand_authz.py tests/conformance/signing/Triaged by Claude Code. Session: https://claude.ai/code/session_018WeXpfffWeUtG5fUsmHcyf
Generated by Claude Code
bokelley commentedon Nov 8, 2026
Closing as completed: the fix is documentation, in #1871 (into
next).VerifyOptions.agent_urlis not a check. The verifier copies it ontoVerifiedSigner.agent_url(verifier.py:556onnext) and never compares it with anything. Passingagent_url=Noneskips no expected-agent check; it only leaves that attribution empty. Which agent signed follows from the key: the JWKS resolver returns keys only for the agents it maps. So verifying against any of a counterparty's agents needs a resolver that spans them, not a set-valuedagent_url.On the RFC 9421 profile, that resolver already exists. #1266 added
SignerKeyResolver.StaticSignerKeysandJwksUriSignerKeysare keyed by agent URL, and each resolved key names its agent (ResolvedSignerKey.agent_url). One verification does one key resolution across all of a counterparty's agents and one nonce claim. Neither workaround from the issue is needed, and no nonce is burned on a first attempt. #1871 documents this for framework verification. It also adds a recipe for callingverify_request_signaturedirectly against a principal identified from a bearer token: the resolver is narrowed to that principal's agent set at step 7, so a key outside the set claims no nonce.brand_authz.checkstays single-valued because the verified signature names exactly one agent, whichmatched_agent_urlreports.The agent is returned with the key on each call, by design. A resolver that remembered the "last matched" agent and was shared across requests would attribute one request to another request's agent under concurrency.
ResolvedSignerKeycarries the agent with the key, so attribution never crosses requests.On the AdCP 3.3 path, this is native. #1502, tracked under #1500, adds
DiscoveredSignerKeys, which resolves a signer from the origin its coveredSignature-Agentnames, andOriginBindings: a principal may own several origins, while each origin maps to only one principal. Staging, production and regional agents are several origins bound to one principal (DR-0023).We are not adding
acceptable_agent_urlsor a multi-agent brand.json resolver:agents[]andjwks_uri(4.0: remove the RFC 9421 brand.json signing profile, brand.json trust fields and identity.brand_json_url adcp#8664).For prebid/salesagent#2276: a child table of agent URLs per principal works with either path. Use one resolver over every onboarded agent, narrowed to the principal's set for bearer-identified requests, as in the recipe in #1871.
docs(signing): verify against a counterparty's several agents (#1871)