Module adcp.webhook_auth
Auth-mode strategies for :class:WebhookSender.
Each strategy owns one authentication mode (RFC 9421 JWK signing,
Standard-Webhooks HMAC, AdCP-legacy HMAC, bearer token) and produces
the auth-bearing headers for one outgoing webhook delivery. The sender
calls :meth:WebhookAuthStrategy.build_auth_headers() per request and
merges the returned headers into the wire request.
Why a strategy interface and not a polymorphic __init__ parameter:
the JWK path needs PrivateKey, the HMAC paths need bytes, the
bearer path needs str. Carrying all three as Optional[…] on
the sender propagates the optional through every typecheck site and
splits _send_bytes into a chain of mode-conditionals. A strategy
collapses that into one method call.
Strategies are constructed by the public :class:WebhookSender
classmethods (:meth:from_jwk, :meth:from_pem,
:meth:from_bearer_token, :meth:from_adcp_legacy_hmac,
:meth:from_standard_webhooks_secret). They are not part of the
public API directly — call the matching WebhookSender.from_*.
Functions
def merge_extra_headers(*,
base: Mapping[str, str],
extra: Mapping[str, str] | None,
reserved: frozenset[str]) ‑> dict[str, str]-
Expand source code
def merge_extra_headers( *, base: Mapping[str, str], extra: Mapping[str, str] | None, reserved: frozenset[str], ) -> dict[str, str]: """Merge ``extra`` into ``base``; raise if ``extra`` collides with reserved. Pre-scan so a bad extra-header doesn't leave half-merged state on the request — the sender promises atomicity here. """ merged = dict(base) if not extra: return merged for key in extra: normalized = str(key).lower() if normalized in reserved or normalized.startswith(":"): raise ValueError( f"extra_headers may not override auth-binding or content-type " f"header {key!r}" ) for key, value in extra.items(): merged[key] = value return mergedMerge
extraintobase; raise ifextracollides with reserved.Pre-scan so a bad extra-header doesn't leave half-merged state on the request — the sender promises atomicity here.
Classes
class AdcpLegacyHmacStrategy (secret: bytes, key_id: str)-
Expand source code
@dataclass(frozen=True) class AdcpLegacyHmacStrategy: """AdCP 3.x legacy HMAC-SHA256 (``X-AdCP-Signature`` / ``X-AdCP-Timestamp``). The wire format matches :func:`adcp.signing.webhook_hmac.verify_webhook_hmac` so a buyer running the AdCP-legacy verifier accepts deliveries from this strategy unchanged. The signed payload is ``f"{timestamp}.{body}"``; the timestamp is read fresh from :func:`time.time` on every call so resends produce a new signature over the same body bytes (the receiver dedupes on ``idempotency_key`` — same body, fresh sig, fresh skew window). """ secret: bytes = field(repr=False) key_id: str def build_auth_headers(self, *, method: str, url: str, body: bytes) -> dict[str, str]: timestamp = str(int(time.time())) message = f"{timestamp}.".encode() + body digest = hmac.new(self.secret, message, hashlib.sha256).hexdigest() return { "X-AdCP-Signature": f"sha256={digest}", "X-AdCP-Timestamp": timestamp, "X-AdCP-Key-Id": self.key_id, } def reserved_headers(self) -> frozenset[str]: return _BASE_RESERVED | { "x-adcp-signature", "x-adcp-timestamp", "x-adcp-key-id", }AdCP 3.x legacy HMAC-SHA256 (
X-AdCP-Signature/X-AdCP-Timestamp).The wire format matches :func:
verify_webhook_hmac()so a buyer running the AdCP-legacy verifier accepts deliveries from this strategy unchanged. The signed payload isf"{timestamp}.{body}"; the timestamp is read fresh from :func:time.timeon every call so resends produce a new signature over the same body bytes (the receiver dedupes onidempotency_key— same body, fresh sig, fresh skew window).Instance variables
var key_id : strvar secret : bytes
Methods
def build_auth_headers(self, *, method: str, url: str, body: bytes) ‑> dict[str, str]-
Expand source code
def build_auth_headers(self, *, method: str, url: str, body: bytes) -> dict[str, str]: timestamp = str(int(time.time())) message = f"{timestamp}.".encode() + body digest = hmac.new(self.secret, message, hashlib.sha256).hexdigest() return { "X-AdCP-Signature": f"sha256={digest}", "X-AdCP-Timestamp": timestamp, "X-AdCP-Key-Id": self.key_id, } def reserved_headers(self) ‑> frozenset[str]-
Expand source code
def reserved_headers(self) -> frozenset[str]: return _BASE_RESERVED | { "x-adcp-signature", "x-adcp-timestamp", "x-adcp-key-id", }
class BearerTokenStrategy (token: str)-
Expand source code
@dataclass(frozen=True) class BearerTokenStrategy: """``Authorization: Bearer <token>`` — for buyers who authenticate the sender at the gateway and don't (yet) verify body signatures. No body signing. The sender's marshaling guarantees still apply (byte-exact serialization, idempotency_key in body), but a buyer treating bearer tokens as the sole authenticity signal MUST also enforce TLS pinning or mTLS at the transport layer — a stolen token is a complete forgery. """ token: str = field(repr=False) def build_auth_headers(self, *, method: str, url: str, body: bytes) -> dict[str, str]: return {"Authorization": f"Bearer {self.token}"} def reserved_headers(self) -> frozenset[str]: return _BASE_RESERVED | {"authorization"}Authorization: Bearer <token>— for buyers who authenticate the sender at the gateway and don't (yet) verify body signatures.No body signing. The sender's marshaling guarantees still apply (byte-exact serialization, idempotency_key in body), but a buyer treating bearer tokens as the sole authenticity signal MUST also enforce TLS pinning or mTLS at the transport layer — a stolen token is a complete forgery.
Instance variables
var token : str
Methods
def build_auth_headers(self, *, method: str, url: str, body: bytes) ‑> dict[str, str]-
Expand source code
def build_auth_headers(self, *, method: str, url: str, body: bytes) -> dict[str, str]: return {"Authorization": f"Bearer {self.token}"} def reserved_headers(self) ‑> frozenset[str]-
Expand source code
def reserved_headers(self) -> frozenset[str]: return _BASE_RESERVED | {"authorization"}
class JwkSignerStrategy (private_key: PrivateKey, key_id: str, alg: str)-
Expand source code
@dataclass(frozen=True) class JwkSignerStrategy: """RFC 9421 webhook-signing profile (the AdCP-conformant default).""" # ``repr=False`` on every secret-bearing field across all strategies: # the auto-generated dataclass repr would otherwise render the # private key / HMAC secret / bearer token in plaintext, defeating # the WebhookSender.__repr__ redaction the moment any code touches # ``self._auth`` (traceback locals, ``vars()``, faulthandler). private_key: PrivateKey = field(repr=False) key_id: str alg: str def build_auth_headers(self, *, method: str, url: str, body: bytes) -> dict[str, str]: signed = sign_webhook( method=method, url=url, headers={"Content-Type": "application/json"}, body=body, private_key=self.private_key, key_id=self.key_id, alg=self.alg, ) return signed.as_dict() def reserved_headers(self) -> frozenset[str]: return _BASE_RESERVED | {"signature", "signature-input", "content-digest"}RFC 9421 webhook-signing profile (the AdCP-conformant default).
Instance variables
var alg : strvar key_id : strvar private_key : cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey | cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey
Methods
def build_auth_headers(self, *, method: str, url: str, body: bytes) ‑> dict[str, str]-
Expand source code
def build_auth_headers(self, *, method: str, url: str, body: bytes) -> dict[str, str]: signed = sign_webhook( method=method, url=url, headers={"Content-Type": "application/json"}, body=body, private_key=self.private_key, key_id=self.key_id, alg=self.alg, ) return signed.as_dict() def reserved_headers(self) ‑> frozenset[str]-
Expand source code
def reserved_headers(self) -> frozenset[str]: return _BASE_RESERVED | {"signature", "signature-input", "content-digest"}
class StandardWebhooksHmacStrategy (secret: bytes, key_id: str)-
Expand source code
@dataclass(frozen=True) class StandardWebhooksHmacStrategy: """standardwebhooks.com v1 (Svix/Resend interop). ``secret`` MUST be the raw HMAC key bytes — pass output of :func:`adcp.signing.standard_webhooks.decode_secret` if the operator holds the canonical ``whsec_<base64>`` form. Building this strategy with the literal ``whsec_`` string would silently produce signatures no conformant verifier accepts, so the constructor doesn't accept a string — types enforce the encoding contract. """ secret: bytes = field(repr=False) key_id: str def build_auth_headers(self, *, method: str, url: str, body: bytes) -> dict[str, str]: # webhook-id is per-delivery (not per-payload). The receiver # uses idempotency_key in the body for dedup; webhook-id is the # spec-required transport identifier and changes on every # send/resend so receivers using webhook-id for replay # detection don't false-positive on a legitimate retry. return sign_standard_webhook( secret=self.secret, msg_id=f"msg_{uuid.uuid4().hex}", timestamp=int(time.time()), body=body, ) def reserved_headers(self) -> frozenset[str]: return _BASE_RESERVED | { _SW_HEADER_ID, _SW_HEADER_TIMESTAMP, _SW_HEADER_SIGNATURE, }standardwebhooks.com v1 (Svix/Resend interop).
secretMUST be the raw HMAC key bytes — pass output of :func:decode_secret()if the operator holds the canonicalwhsec_<base64>form. Building this strategy with the literalwhsec_string would silently produce signatures no conformant verifier accepts, so the constructor doesn't accept a string — types enforce the encoding contract.Instance variables
var key_id : strvar secret : bytes
Methods
def build_auth_headers(self, *, method: str, url: str, body: bytes) ‑> dict[str, str]-
Expand source code
def build_auth_headers(self, *, method: str, url: str, body: bytes) -> dict[str, str]: # webhook-id is per-delivery (not per-payload). The receiver # uses idempotency_key in the body for dedup; webhook-id is the # spec-required transport identifier and changes on every # send/resend so receivers using webhook-id for replay # detection don't false-positive on a legitimate retry. return sign_standard_webhook( secret=self.secret, msg_id=f"msg_{uuid.uuid4().hex}", timestamp=int(time.time()), body=body, ) def reserved_headers(self) ‑> frozenset[str]-
Expand source code
def reserved_headers(self) -> frozenset[str]: return _BASE_RESERVED | { _SW_HEADER_ID, _SW_HEADER_TIMESTAMP, _SW_HEADER_SIGNATURE, }
class WebhookAuthStrategy (*args, **kwargs)-
Expand source code
class WebhookAuthStrategy(Protocol): """Produce the auth-bearing headers for one webhook POST. Implementations are responsible for any per-call timestamping or nonce so retries (``WebhookSender.resend``) re-sign with fresh metadata over the same body — the sender simply re-enters ``build_auth_headers``. """ def build_auth_headers(self, *, method: str, url: str, body: bytes) -> dict[str, str]: ... def reserved_headers(self) -> frozenset[str]: """Lower-case header names callers MUST NOT override via ``extra_headers``.""" ...Produce the auth-bearing headers for one webhook POST.
Implementations are responsible for any per-call timestamping or nonce so retries (
WebhookSender.resend) re-sign with fresh metadata over the same body — the sender simply re-entersbuild_auth_headers.Ancestors
- typing.Protocol
- typing.Generic
Methods
def build_auth_headers(self, *, method: str, url: str, body: bytes) ‑> dict[str, str]-
Expand source code
def build_auth_headers(self, *, method: str, url: str, body: bytes) -> dict[str, str]: ... def reserved_headers(self) ‑> frozenset[str]-
Expand source code
def reserved_headers(self) -> frozenset[str]: """Lower-case header names callers MUST NOT override via ``extra_headers``.""" ...Lower-case header names callers MUST NOT override via
extra_headers.