Skip to content

Commit 3857bbe

Browse files
committed
feat(client)!: complete JavaScript v13 parity hardening
BREAKING CHANGE: ADCPClient.handle_webhook now rejects unsigned MCP callbacks unless the isolated-receiver compatibility escape is explicitly enabled.
1 parent f443d7d commit 3857bbe

15 files changed

Lines changed: 532 additions & 56 deletions

MIGRATION_v6_to_v7.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ SDK 7 continues to interoperate with AdCP 3.0 and 3.1 agents.
3232
waiting for a duplicate completion webhook.
3333
8. Exercise callback validation and multi-tenant isolation in staging before
3434
production rollout.
35-
9. Configure `webhook_secret` on every public MCP callback receiver. If an
36-
endpoint is isolated from untrusted networks and must temporarily accept
37-
unsigned callbacks, opt in explicitly with
38-
`allow_unauthenticated_webhooks=True`.
35+
9. Use `WebhookReceiver` for public MCP callback endpoints so RFC 9421
36+
verification and delivery deduplication happen before application code.
37+
Configure `webhook_secret` on `ADCPClient` only for registrations that
38+
explicitly select the deprecated `HMAC-SHA256` fallback.
3939

4040
## Canonical creatives are the primary API
4141

@@ -201,11 +201,11 @@ DNS pinning must provide a custom sender and enforce resolution at connection
201201
time. A push-configured handoff with no available delivery transport is now
202202
rejected before task creation instead of being accepted and silently dropped.
203203

204-
`ADCPClient.handle_webhook()` also fails closed for unsigned MCP callbacks when
205-
the client has no `webhook_secret`. The previous fail-open behavior is
206-
available only through the explicit `allow_unauthenticated_webhooks=True`
207-
compatibility escape. Do not enable that option on an Internet-reachable
208-
receiver.
204+
Public MCP callbacks should enter through `WebhookReceiver`, which verifies the
205+
AdCP RFC 9421 profile, deduplicates delivery, and parses the authenticated raw
206+
body. `ADCPClient.handle_webhook()` is the legacy HMAC convenience path; use it
207+
only when the callback registration explicitly selects `HMAC-SHA256` and the
208+
client is configured with the same `webhook_secret`.
209209

210210
Account registries, sessions, proposals, notification stores, and reference
211211
seller state now enforce tenant ownership. Test fixtures or application code

MIGRATION_v7_to_v8.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Migrating from Python SDK 7 to 8
2+
3+
SDK 8 makes the legacy `ADCPClient.handle_webhook()` convenience path fail
4+
closed. Calls without a configured `webhook_secret` no longer accept unsigned
5+
MCP callbacks.
6+
7+
For AdCP-conformant public endpoints, migrate delivery to `WebhookReceiver`.
8+
It verifies RFC 9421 signatures, deduplicates retries, and parses the
9+
authenticated raw body:
10+
11+
```python
12+
from adcp.webhooks import WebhookReceiver
13+
14+
outcome = await receiver.receive(
15+
method=request.method,
16+
url=str(request.url),
17+
headers=dict(request.headers),
18+
body=await request.body(),
19+
)
20+
```
21+
22+
If a 3.x registration explicitly selects the deprecated `HMAC-SHA256`
23+
fallback, configure the same shared secret on `ADCPClient` and pass the raw
24+
request body to `handle_webhook()`. An endpoint that is isolated from untrusted
25+
networks may temporarily retain unsigned legacy callbacks with
26+
`allow_unauthenticated_webhooks=True`; multi-agent clients must scope this
27+
escape by agent ID.

README.md

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -494,41 +494,53 @@ for result in results:
494494
print(f"Async: webhook to {result.submitted.webhook_url}")
495495
```
496496

497-
### Webhook Handling
498-
Single endpoint handles all webhooks:
497+
### Legacy HMAC webhook handling
498+
499+
For registrations that explicitly select `HMAC-SHA256`, a single endpoint can
500+
route callbacks through the client helper. Capture the raw body before parsing;
501+
those exact bytes are what the signature authenticates:
499502

500503
```python
504+
import json
501505
from fastapi import FastAPI, Request
502506

503507
app = FastAPI()
504508

505509
@app.post("/webhook/{task_type}/{agent_id}/{operation_id}")
506510
async def webhook(task_type: str, agent_id: str, operation_id: str, request: Request):
507-
payload = await request.json()
508-
payload["task_type"] = task_type
509-
payload["operation_id"] = operation_id
511+
raw_body = await request.body()
512+
payload = json.loads(raw_body)
510513

511514
# Route to agent client - handlers fire automatically
512515
agent = client.agent(agent_id)
513516
await agent.handle_webhook(
514-
payload,
515-
request.headers.get("x-adcp-signature")
517+
payload=payload,
518+
task_type=task_type,
519+
operation_id=operation_id,
520+
signature=request.headers.get("x-adcp-signature"),
521+
timestamp=request.headers.get("x-adcp-timestamp"),
522+
raw_body=raw_body,
516523
)
517524

518525
return {"received": True}
519526
```
520527

521-
### Security
522-
Webhook signature verification built-in:
528+
### Legacy HMAC callback verification
529+
530+
For AdCP 3.x registrations that explicitly select the deprecated
531+
`HMAC-SHA256` authentication mode, shared-secret verification is available on
532+
the client helper:
523533

524534
```python
525535
client = ADCPMultiAgentClient(
526536
agents=agents,
527537
webhook_secret=os.getenv("WEBHOOK_SECRET")
528538
)
529-
# Signatures verified automatically on handle_webhook()
539+
# Legacy HMAC signatures are verified on handle_webhook().
530540
```
531541

542+
For the protocol-default RFC 9421 mode, use `WebhookReceiver` as shown below.
543+
532544
### Signed webhooks (AdCP 3.0): receiver quickstart
533545

534546
AdCP 3.0 webhooks are signed under the RFC 9421 profile

src/adcp/__init__.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,15 +208,30 @@ def _resolve_version() -> str:
208208
"ArtifactWebhookPayload",
209209
"AssetContentType",
210210
"AssetInstance",
211+
"AssetInstanceType",
211212
"AssetVariant",
213+
"AudioContent",
212214
"AudienceSource",
215+
"CssContent",
216+
"DaastAsset",
217+
"HtmlContent",
218+
"ImageContent",
219+
"JavascriptContent",
220+
"MarkdownAsset",
221+
"TextContent",
222+
"UrlContent",
223+
"VastAsset",
224+
"VideoContent",
225+
"WebhookContent",
213226
"AuthorizationRequiredDetails",
214227
"BrandReference",
215228
"BrandSource",
229+
"BriefAsset",
216230
"BuyingMode",
217231
"CardAsset",
218232
"Catalog",
219233
"CatalogAction",
234+
"CatalogAsset",
220235
"CatalogFieldBinding",
221236
"CatalogFieldMapping",
222237
"CatalogGroupBinding",
@@ -1023,12 +1038,27 @@ def get_adcp_version() -> str:
10231038
"FormatOptionReference",
10241039
"AssetContentType",
10251040
"AssetInstance",
1041+
"AssetInstanceType",
10261042
"AssetVariant",
1043+
"AudioContent",
1044+
"BriefAsset",
10271045
"CardAsset",
1046+
"CatalogAsset",
1047+
"CssContent",
1048+
"DaastAsset",
10281049
"DaastTrackerAsset",
1050+
"HtmlContent",
1051+
"ImageContent",
1052+
"JavascriptContent",
1053+
"MarkdownAsset",
10291054
"PixelTrackerAsset",
10301055
"PublishedPostAsset",
1056+
"TextContent",
1057+
"UrlContent",
1058+
"VastAsset",
10311059
"VastTrackerAsset",
1060+
"VideoContent",
1061+
"WebhookContent",
10321062
"ZipAsset",
10331063
"Product",
10341064
"ProductFormatDeclaration",
@@ -1474,18 +1504,22 @@ def get_adcp_version() -> str:
14741504
ArtifactWebhookPayload,
14751505
AssetContentType,
14761506
AssetInstance,
1507+
AssetInstanceType,
14771508
AssetVariant,
14781509
AudienceSource,
1510+
AudioContent,
14791511
AuthorizationRequiredDetails,
14801512
# Core domain types
14811513
BrandReference,
14821514
BrandSource,
14831515
# Creative Operations
1516+
BriefAsset,
14841517
BuyingMode,
14851518
CardAsset,
14861519
# Catalog types
14871520
Catalog,
14881521
CatalogAction,
1522+
CatalogAsset,
14891523
CatalogFieldBinding,
14901524
CatalogFieldMapping,
14911525
CatalogGroupBinding,
@@ -1521,6 +1555,8 @@ def get_adcp_version() -> str:
15211555
# Status enums (for control flow)
15221556
CreativeStatus,
15231557
CreativeVariant,
1558+
CssContent,
1559+
DaastAsset,
15241560
DaastTrackerAsset,
15251561
DateRange,
15261562
DatetimeRange,
@@ -1563,9 +1599,12 @@ def get_adcp_version() -> str:
15631599
GetTaskStatusRequest,
15641600
GetTaskStatusResponse,
15651601
Gtin,
1602+
HtmlContent,
15661603
IdentityMatchRequest,
15671604
IdentityMatchResponse,
15681605
IdentityMatchTmpxMacro,
1606+
ImageContent,
1607+
JavascriptContent,
15691608
KellerType,
15701609
LegacyBuildCreativeErrorResponse,
15711610
LegacyBuildCreativeRequest,
@@ -1622,6 +1661,7 @@ def get_adcp_version() -> str:
16221661
# Event Operations
16231662
LogEventRequest,
16241663
LogEventResponse,
1664+
MarkdownAsset,
16251665
McpWebhookPayload,
16261666
MediaBuy,
16271667
MediaBuyDeliveryStatus,
@@ -1696,14 +1736,17 @@ def get_adcp_version() -> str:
16961736
SyncPlansRequest,
16971737
SyncPlansResponse,
16981738
TargetingOverlay,
1739+
TextContent,
16991740
TimeBasedPricingOption,
17001741
TimeUnit,
17011742
Transform,
17021743
UpdateFrequency,
17031744
UpdateMediaBuyRequest,
17041745
UpdateMediaBuyResponse,
1746+
UrlContent,
17051747
ValidateInputRequest,
17061748
ValidateInputResponse,
1749+
VastAsset,
17071750
VastTrackerAsset,
17081751
VcpmAuctionPricingOption,
17091752
VcpmFixedRatePricingOption,
@@ -1721,9 +1764,11 @@ def get_adcp_version() -> str:
17211764
VerifyBrandClaimsResponseBulk,
17221765
VerifyBrandClaimsSignedResponse,
17231766
VerifyBrandClaimsSignedSuccessPayload,
1767+
VideoContent,
17241768
WcagLevel,
17251769
WebhookChallenge,
17261770
WebhookChallengeResponse,
1771+
WebhookContent,
17271772
WholesaleFeedEvent,
17281773
WholesaleFeedWebhook,
17291774
ZipAsset,

src/adcp/client.py

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import os
1111
import time
1212
import warnings
13-
from collections.abc import Callable, Iterator
13+
from collections.abc import Callable, Iterator, Mapping
1414
from datetime import datetime, timezone
1515
from typing import TYPE_CHECKING, Any, TypedDict, cast
1616
from uuid import uuid4
@@ -578,6 +578,9 @@ def __init__(
578578
"""
579579
self._adcp_version: str = resolve_adcp_version(adcp_version)
580580
self._server_version: str | None = _resolve_server_version(server_version)
581+
if type(allow_unauthenticated_webhooks) is not bool:
582+
raise TypeError("allow_unauthenticated_webhooks must be a bool")
583+
581584
self.agent_config = agent_config
582585
self.webhook_url_template = webhook_url_template
583586
self.webhook_secret = webhook_secret
@@ -5087,7 +5090,18 @@ async def _handle_mcp_webhook(
50875090
f"Webhook signature verification failed for agent {self.agent_config.id}"
50885091
)
50895092
raise ADCPWebhookSignatureError("Invalid webhook signature")
5090-
elif not self.allow_unauthenticated_webhooks:
5093+
if raw_body is None: # Defensive type narrowing; verifier rejects this above.
5094+
raise ADCPWebhookSignatureError("Signed webhook raw body is required")
5095+
try:
5096+
authenticated_payload = json.loads(raw_body)
5097+
except (TypeError, ValueError, UnicodeDecodeError) as exc:
5098+
raise ADCPWebhookSignatureError("Invalid signed webhook body") from exc
5099+
if not isinstance(authenticated_payload, dict):
5100+
raise ADCPWebhookSignatureError("Signed webhook body must be a JSON object")
5101+
# Process the bytes that were authenticated, not a separately
5102+
# supplied parsed object that middleware could have transformed.
5103+
payload = cast(dict[str, Any], authenticated_payload)
5104+
elif self.allow_unauthenticated_webhooks is not True:
50915105
raise ADCPWebhookSignatureError(
50925106
"MCP webhook cannot be authenticated because webhook_secret is not configured; "
50935107
"configure a secret or explicitly set allow_unauthenticated_webhooks=True only "
@@ -5106,11 +5120,8 @@ async def _handle_mcp_webhook(
51065120
task_type=task_type,
51075121
timestamp=datetime.now(timezone.utc).isoformat(),
51085122
metadata={
5109-
"payload": (
5110-
payload
5111-
if preserve_legacy_identity
5112-
else strip_legacy_creative_identity(payload)
5113-
),
5123+
"task_id": webhook.task_id,
5124+
"status": webhook.status.value,
51145125
"protocol": "mcp",
51155126
},
51165127
)
@@ -5479,7 +5490,7 @@ def __init__(
54795490
adcp_version: str | dict[str, str] | None = None,
54805491
legacy_format_converter: LegacyFormatConverter | None = None,
54815492
canonical_format_legacy_resolver: CanonicalFormatLegacyResolver | None = None,
5482-
allow_unauthenticated_webhooks: bool = False,
5493+
allow_unauthenticated_webhooks: bool | Mapping[str, bool] = False,
54835494
):
54845495
"""
54855496
Initialize multi-agent client.
@@ -5493,8 +5504,10 @@ def __init__(
54935504
signing: Optional RFC 9421 signing config forwarded to every
54945505
per-agent ADCPClient. The same identity signs traffic to
54955506
all agents. See ADCPClient.__init__ for details.
5496-
allow_unauthenticated_webhooks: Explicit compatibility escape
5497-
forwarded to each client. Defaults to False.
5507+
allow_unauthenticated_webhooks: Explicit compatibility escape.
5508+
A mapping scopes the opt-in by agent ID; omitted IDs remain
5509+
protected. A uniform True is accepted only for a single-agent
5510+
collection. Defaults to False.
54985511
adcp_version: AdCP protocol release pin. Three forms:
54995512
55005513
- ``None`` (default): every per-agent ADCPClient resolves
@@ -5510,6 +5523,31 @@ def __init__(
55105523
See ADCPClient.__init__ for per-instance semantics.
55115524
Cross-major pins raise ConfigurationError at construction.
55125525
"""
5526+
agent_ids = {agent.id for agent in agents}
5527+
if isinstance(allow_unauthenticated_webhooks, Mapping):
5528+
unknown_ids = set(allow_unauthenticated_webhooks) - agent_ids
5529+
if unknown_ids:
5530+
unknown = ", ".join(sorted(unknown_ids))
5531+
raise ValueError(
5532+
"allow_unauthenticated_webhooks contains unknown agent IDs: " + unknown
5533+
)
5534+
if any(type(value) is not bool for value in allow_unauthenticated_webhooks.values()):
5535+
raise TypeError("allow_unauthenticated_webhooks mapping values must be bools")
5536+
per_agent_unauthenticated = dict(allow_unauthenticated_webhooks)
5537+
else:
5538+
if type(allow_unauthenticated_webhooks) is not bool:
5539+
raise TypeError(
5540+
"allow_unauthenticated_webhooks must be a bool or mapping of agent IDs to bools"
5541+
)
5542+
if allow_unauthenticated_webhooks is True and len(agents) > 1:
5543+
raise ValueError(
5544+
"allow_unauthenticated_webhooks=True cannot be applied to multiple agents; "
5545+
"pass a mapping keyed by the isolated agent IDs"
5546+
)
5547+
per_agent_unauthenticated = {
5548+
agent.id: allow_unauthenticated_webhooks for agent in agents
5549+
}
5550+
55135551
# Per-agent map → resolve each pin individually for the dict form;
55145552
# otherwise use the uniform pin for all agents.
55155553
if isinstance(adcp_version, dict):
@@ -5528,7 +5566,7 @@ def __init__(
55285566
adcp_version=self._per_agent_versions.get(agent.id, default_pin),
55295567
legacy_format_converter=legacy_format_converter,
55305568
canonical_format_legacy_resolver=canonical_format_legacy_resolver,
5531-
allow_unauthenticated_webhooks=allow_unauthenticated_webhooks,
5569+
allow_unauthenticated_webhooks=per_agent_unauthenticated.get(agent.id, False),
55325570
)
55335571
for agent in agents
55345572
}
@@ -5545,7 +5583,7 @@ def __init__(
55455583
adcp_version=self._adcp_version,
55465584
legacy_format_converter=legacy_format_converter,
55475585
canonical_format_legacy_resolver=canonical_format_legacy_resolver,
5548-
allow_unauthenticated_webhooks=allow_unauthenticated_webhooks,
5586+
allow_unauthenticated_webhooks=per_agent_unauthenticated.get(agent.id, False),
55495587
)
55505588
for agent in agents
55515589
}

0 commit comments

Comments
 (0)