|
| 1 | +"""Webhook signature verification for Foundry deliveries. |
| 2 | +
|
| 3 | +Foundry POSTs signed events to the webhook_url registered on an experiment. |
| 4 | +Each delivery carries X-Adaptyv-Signature, which is "sha256=<hex>" over the |
| 5 | +HMAC-SHA256 of the raw request body, alongside X-Adaptyv-Event and |
| 6 | +X-Adaptyv-Delivery-Id. The contract is documented at |
| 7 | +https://docs.adaptyvbio.com/api-reference/api-introduction. |
| 8 | +
|
| 9 | +verify() is framework-agnostic on purpose: hand it the raw body bytes, the |
| 10 | +request headers, and the webhook secret, and it either returns a WebhookEvent |
| 11 | +or raises WebhookVerificationError. The README has FastAPI and framework-free |
| 12 | +handlers built on it. |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import hashlib |
| 18 | +import hmac |
| 19 | +import json |
| 20 | +from collections.abc import Mapping |
| 21 | +from dataclasses import dataclass |
| 22 | +from typing import Any |
| 23 | + |
| 24 | +from adaptyv.exceptions import WebhookPayloadError, WebhookVerificationError |
| 25 | + |
| 26 | +SIGNATURE_HEADER = "X-Adaptyv-Signature" |
| 27 | + |
| 28 | +_SIGNATURE_PREFIX = "sha256=" |
| 29 | +_DIGEST_SIZE = hashlib.sha256().digest_size |
| 30 | + |
| 31 | + |
| 32 | +@dataclass(frozen=True) |
| 33 | +class WebhookEvent: |
| 34 | + """A webhook delivery whose signature has been verified. |
| 35 | +
|
| 36 | + Attributes: |
| 37 | + event: Event slug, for example "experiment_update". |
| 38 | + delivery_id: Unique id for this delivery attempt. Foundry retries a |
| 39 | + failed delivery up to three times, so a handler that errors once |
| 40 | + will see this id again and should use it as its dedupe key. |
| 41 | + payload: The whole parsed envelope, including timestamp, api_version, |
| 42 | + and the nested data object. |
| 43 | + """ |
| 44 | + |
| 45 | + event: str |
| 46 | + delivery_id: str |
| 47 | + payload: dict[str, Any] |
| 48 | + |
| 49 | + |
| 50 | +def _get_header(headers: Mapping[str, str], name: str) -> str | None: |
| 51 | + """Look up a header without depending on the caller's capitalization. |
| 52 | +
|
| 53 | + Frameworks disagree here: httpx and Starlette hand back case-insensitive |
| 54 | + mappings, while dict(request.headers) hands back whatever casing arrived on |
| 55 | + the wire. HTTP header names are case-insensitive, so this treats them so. |
| 56 | + """ |
| 57 | + value = headers.get(name) |
| 58 | + if value is not None: |
| 59 | + return value |
| 60 | + |
| 61 | + wanted = name.lower() |
| 62 | + for key, candidate in headers.items(): |
| 63 | + if key.lower() == wanted: |
| 64 | + return candidate |
| 65 | + return None |
| 66 | + |
| 67 | + |
| 68 | +def _parse_signature(headers: Mapping[str, str]) -> bytes: |
| 69 | + """Decode the digest carried by the signature header. |
| 70 | +
|
| 71 | + Returns the digest decoded to bytes rather than the hex text it arrived as, |
| 72 | + so the comparison cannot turn on the casing the sender chose. |
| 73 | +
|
| 74 | + Raises: |
| 75 | + WebhookVerificationError: The header is absent or cannot be parsed. |
| 76 | + """ |
| 77 | + header = _get_header(headers, SIGNATURE_HEADER) |
| 78 | + if header is None: |
| 79 | + raise WebhookVerificationError(f"{SIGNATURE_HEADER} header is missing from the delivery") |
| 80 | + |
| 81 | + if not header.startswith(_SIGNATURE_PREFIX): |
| 82 | + raise WebhookVerificationError( |
| 83 | + f"{SIGNATURE_HEADER} must be formatted as 'sha256=<hex>', got {header!r}" |
| 84 | + ) |
| 85 | + |
| 86 | + hex_digest = header[len(_SIGNATURE_PREFIX) :] |
| 87 | + try: |
| 88 | + digest = bytes.fromhex(hex_digest) |
| 89 | + except ValueError as exc: |
| 90 | + raise WebhookVerificationError( |
| 91 | + f"{SIGNATURE_HEADER} digest is not valid hex: {hex_digest!r}" |
| 92 | + ) from exc |
| 93 | + |
| 94 | + if len(digest) != _DIGEST_SIZE: |
| 95 | + raise WebhookVerificationError( |
| 96 | + f"{SIGNATURE_HEADER} digest must be {_DIGEST_SIZE} bytes, got {len(digest)}" |
| 97 | + ) |
| 98 | + return digest |
| 99 | + |
| 100 | + |
| 101 | +def _parse_payload(body: bytes) -> dict[str, Any]: |
| 102 | + """Parse an already verified body into an event envelope. |
| 103 | +
|
| 104 | + Raises: |
| 105 | + WebhookPayloadError: The body is not a JSON object. Not a verification |
| 106 | + failure: the signature already proved where these bytes came from. |
| 107 | + """ |
| 108 | + try: |
| 109 | + payload = json.loads(body) |
| 110 | + except ValueError as exc: |
| 111 | + raise WebhookPayloadError(f"Webhook body is not valid JSON: {exc}") from exc |
| 112 | + |
| 113 | + if not isinstance(payload, dict): |
| 114 | + raise WebhookPayloadError( |
| 115 | + f"Webhook body must be a JSON object, got {type(payload).__name__}" |
| 116 | + ) |
| 117 | + return payload |
| 118 | + |
| 119 | + |
| 120 | +def _required_str(payload: dict[str, Any], key: str) -> str: |
| 121 | + """Read an envelope field that WebhookEvent types as str. |
| 122 | +
|
| 123 | + Raises: |
| 124 | + WebhookPayloadError: The field is absent or is not a string. |
| 125 | + """ |
| 126 | + value = payload.get(key) |
| 127 | + if not isinstance(value, str): |
| 128 | + raise WebhookPayloadError(f"Webhook payload has no string {key!r} field") |
| 129 | + return value |
| 130 | + |
| 131 | + |
| 132 | +def verify(body: bytes, headers: Mapping[str, str], secret: str) -> WebhookEvent: |
| 133 | + """Verify a webhook delivery and return the event it carries. |
| 134 | +
|
| 135 | + Args: |
| 136 | + body: The raw request body, exactly as it arrived. A str or an already |
| 137 | + parsed payload is refused, because re-serializing a payload changes |
| 138 | + the bytes the signature was computed over. |
| 139 | + headers: The request headers, looked up case-insensitively. |
| 140 | + secret: The webhook secret for this endpoint. |
| 141 | +
|
| 142 | + Returns: |
| 143 | + The verified event. Its event name and delivery id come from the signed |
| 144 | + body, not from the X-Adaptyv-Event and X-Adaptyv-Delivery-Id headers, |
| 145 | + which the signature does not cover. The whole envelope is exposed |
| 146 | + unchanged on payload, so fields this SDK does not know about pass |
| 147 | + through rather than being dropped. |
| 148 | +
|
| 149 | + Raises: |
| 150 | + WebhookVerificationError: The delivery did not prove it came from |
| 151 | + Foundry. The body is not bytes, the secret is empty, the signature |
| 152 | + header is missing or malformed, or the signature does not match. |
| 153 | + Answer 4xx: resending would not help. |
| 154 | + WebhookPayloadError: The signature checked out but the envelope could |
| 155 | + not be read. Answer 5xx or accept and log, never 4xx, or a genuine |
| 156 | + event is discarded and the API is told not to retry it. |
| 157 | + """ |
| 158 | + if not isinstance(body, bytes): |
| 159 | + raise WebhookVerificationError( |
| 160 | + "Webhook body must be the raw request body as bytes, got " |
| 161 | + f"{type(body).__name__}. Re-serializing a parsed payload produces " |
| 162 | + "different bytes than the ones that were signed." |
| 163 | + ) |
| 164 | + |
| 165 | + if not isinstance(secret, str) or not secret.strip(): |
| 166 | + raise WebhookVerificationError( |
| 167 | + "Webhook secret must be a non-empty string. An unset secret still " |
| 168 | + "produces a valid HMAC, so verification would pass for every request " |
| 169 | + "instead of failing closed." |
| 170 | + ) |
| 171 | + |
| 172 | + provided = _parse_signature(headers) |
| 173 | + expected = hmac.new(secret.encode(), body, hashlib.sha256).digest() |
| 174 | + if not hmac.compare_digest(expected, provided): |
| 175 | + raise WebhookVerificationError("Webhook signature does not match the request body") |
| 176 | + |
| 177 | + # Parsed only after the signature checks out, so an unverified body never |
| 178 | + # reaches the JSON parser. |
| 179 | + payload = _parse_payload(body) |
| 180 | + return WebhookEvent( |
| 181 | + event=_required_str(payload, "event"), |
| 182 | + delivery_id=_required_str(payload, "delivery_id"), |
| 183 | + payload=payload, |
| 184 | + ) |
0 commit comments