Skip to content

Commit e14ac34

Browse files
authored
refactor(api): share webhook signature verification (openai#3704)
- [x] I understand that this repository is auto-generated and my pull request may not be merged ## Changes being requested Move the identical webhook header/timestamp/HMAC verification core into the SDK-owned `lib/_webhooks.py` module. The public sync and async wrappers retain their signatures, client-secret fallback, mismatch errors, and their existing exception-chaining differences. Both `unwrap` methods are unchanged. The helper keeps the same replay-window checks, secret decoding, signed bytes, signature order, and `hmac.compare_digest` calls. This is a behavior-preserving extraction, not a change to accepted signatures or verification policy. Generation metadata, dependencies, workflows, and the API reference are unchanged. The verified custom-code report keeps 40 mixed files and changes only the webhook resource's patch: **+179/-3 → +89/-3**. The other 39 customizations are unchanged. ## Additional context & links Please get SDK CODEOWNER review for this verification-boundary change. The existing 71 cases remain unchanged in [`tests/lib/test_webhooks.py::TestWebhooks`](https://github.com/openai/openai-python/blob/ece4324da0b96f848b48bdef090a372ff0a1db26/tests/lib/test_webhooks.py#L32) and [`tests/lib/test_webhooks.py::TestAsyncWebhooks`](https://github.com/openai/openai-python/blob/ece4324da0b96f848b48bdef090a372ff0a1db26/tests/lib/test_webhooks.py#L170). The new [`tests/lib/test_webhook_signature.py`](https://github.com/openai/openai-python/blob/35d955800683b814490c7a583b91611c30df9a71/tests/lib/test_webhook_signature.py) adds 87 cases for raw/prefixed/empty secrets, text/bytes payloads, replay-window boundaries, exact timestamp text, header order, malformed inputs, and constant-time comparison order. In particular, [`test_missing_secret_preserves_wrapper_exception_chaining`](https://github.com/openai/openai-python/blob/35d955800683b814490c7a583b91611c30df9a71/tests/lib/test_webhook_signature.py#L146) and [`test_mismatch_preserves_wrapper_exception_chaining`](https://github.com/openai/openai-python/blob/35d955800683b814490c7a583b91611c30df9a71/tests/lib/test_webhook_signature.py#L161) pin the existing sync/async differences. Validation: exact-source/AST preservation check; `./scripts/format`; `./scripts/lint` (Ruff, Pyright, mypy, import); wheel and sdist builds with the new helper included; **158 tests passed under Pydantic v2 and 158 under v1**.
1 parent ece4324 commit e14ac34

3 files changed

Lines changed: 278 additions & 93 deletions

File tree

‎src/openai/lib/_webhooks.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
import hmac
4+
import time
5+
import base64
6+
import hashlib
7+
8+
from .._types import HeadersLike
9+
from .._utils import get_required_header
10+
from .._exceptions import InvalidWebhookSignatureError
11+
12+
13+
def webhook_signature_matches(
14+
payload: str | bytes,
15+
headers: HeadersLike,
16+
*,
17+
secret: str,
18+
tolerance: int,
19+
) -> bool:
20+
"""Validate the replay window and compare the supplied signatures."""
21+
signature_header = get_required_header(headers, "webhook-signature")
22+
timestamp = get_required_header(headers, "webhook-timestamp")
23+
webhook_id = get_required_header(headers, "webhook-id")
24+
25+
# Validate timestamp to prevent replay attacks
26+
try:
27+
timestamp_seconds = int(timestamp)
28+
except ValueError:
29+
raise InvalidWebhookSignatureError("Invalid webhook timestamp format") from None
30+
31+
now = int(time.time())
32+
33+
if now - timestamp_seconds > tolerance:
34+
raise InvalidWebhookSignatureError("Webhook timestamp is too old") from None
35+
36+
if timestamp_seconds > now + tolerance:
37+
raise InvalidWebhookSignatureError("Webhook timestamp is too new") from None
38+
39+
# Extract signatures from v1,<base64> format
40+
# The signature header can have multiple values, separated by spaces.
41+
# Each value is in the format v1,<base64>. We should accept if any match.
42+
signatures: list[str] = []
43+
for part in signature_header.split():
44+
if part.startswith("v1,"):
45+
signatures.append(part[3:])
46+
else:
47+
signatures.append(part)
48+
49+
# Decode the secret if it starts with whsec_
50+
if secret.startswith("whsec_"):
51+
decoded_secret = base64.b64decode(secret[6:])
52+
else:
53+
decoded_secret = secret.encode()
54+
55+
body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
56+
57+
# Prepare the signed payload (OpenAI uses webhookId.timestamp.payload format)
58+
signed_payload = f"{webhook_id}.{timestamp}.{body}"
59+
expected_signature = base64.b64encode(
60+
hmac.new(decoded_secret, signed_payload.encode(), hashlib.sha256).digest()
61+
).decode()
62+
63+
# Accept if any signature matches
64+
return any(hmac.compare_digest(expected_signature, sig) for sig in signatures)

‎src/openai/resources/webhooks/webhooks.py‎

Lines changed: 3 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,14 @@
22

33
from __future__ import annotations
44

5-
import hmac
65
import json
7-
import time
8-
import base64
9-
import hashlib
106
from typing import cast
117

128
from ..._types import HeadersLike
13-
from ..._utils import get_required_header
149
from ..._models import construct_type
1510
from ..._resource import SyncAPIResource, AsyncAPIResource
1611
from ..._exceptions import InvalidWebhookSignatureError
12+
from ...lib._webhooks import webhook_signature_matches as _webhook_signature_matches
1713
from ...types.webhooks.unwrap_webhook_event import UnwrapWebhookEvent
1814

1915
__all__ = ["Webhooks", "AsyncWebhooks"]
@@ -66,50 +62,7 @@ def verify_signature(
6662
"on the client class, OpenAI(webhook_secret='123'), or passed to this function"
6763
)
6864

69-
signature_header = get_required_header(headers, "webhook-signature")
70-
timestamp = get_required_header(headers, "webhook-timestamp")
71-
webhook_id = get_required_header(headers, "webhook-id")
72-
73-
# Validate timestamp to prevent replay attacks
74-
try:
75-
timestamp_seconds = int(timestamp)
76-
except ValueError:
77-
raise InvalidWebhookSignatureError("Invalid webhook timestamp format") from None
78-
79-
now = int(time.time())
80-
81-
if now - timestamp_seconds > tolerance:
82-
raise InvalidWebhookSignatureError("Webhook timestamp is too old") from None
83-
84-
if timestamp_seconds > now + tolerance:
85-
raise InvalidWebhookSignatureError("Webhook timestamp is too new") from None
86-
87-
# Extract signatures from v1,<base64> format
88-
# The signature header can have multiple values, separated by spaces.
89-
# Each value is in the format v1,<base64>. We should accept if any match.
90-
signatures: list[str] = []
91-
for part in signature_header.split():
92-
if part.startswith("v1,"):
93-
signatures.append(part[3:])
94-
else:
95-
signatures.append(part)
96-
97-
# Decode the secret if it starts with whsec_
98-
if secret.startswith("whsec_"):
99-
decoded_secret = base64.b64decode(secret[6:])
100-
else:
101-
decoded_secret = secret.encode()
102-
103-
body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
104-
105-
# Prepare the signed payload (OpenAI uses webhookId.timestamp.payload format)
106-
signed_payload = f"{webhook_id}.{timestamp}.{body}"
107-
expected_signature = base64.b64encode(
108-
hmac.new(decoded_secret, signed_payload.encode(), hashlib.sha256).digest()
109-
).decode()
110-
111-
# Accept if any signature matches
112-
if not any(hmac.compare_digest(expected_signature, sig) for sig in signatures):
65+
if not _webhook_signature_matches(payload, headers, secret=secret, tolerance=tolerance):
11366
raise InvalidWebhookSignatureError(
11467
"The given webhook signature does not match the expected signature"
11568
) from None
@@ -163,48 +116,5 @@ def verify_signature(
163116
"on the client class, OpenAI(webhook_secret='123'), or passed to this function"
164117
) from None
165118

166-
signature_header = get_required_header(headers, "webhook-signature")
167-
timestamp = get_required_header(headers, "webhook-timestamp")
168-
webhook_id = get_required_header(headers, "webhook-id")
169-
170-
# Validate timestamp to prevent replay attacks
171-
try:
172-
timestamp_seconds = int(timestamp)
173-
except ValueError:
174-
raise InvalidWebhookSignatureError("Invalid webhook timestamp format") from None
175-
176-
now = int(time.time())
177-
178-
if now - timestamp_seconds > tolerance:
179-
raise InvalidWebhookSignatureError("Webhook timestamp is too old") from None
180-
181-
if timestamp_seconds > now + tolerance:
182-
raise InvalidWebhookSignatureError("Webhook timestamp is too new") from None
183-
184-
# Extract signatures from v1,<base64> format
185-
# The signature header can have multiple values, separated by spaces.
186-
# Each value is in the format v1,<base64>. We should accept if any match.
187-
signatures: list[str] = []
188-
for part in signature_header.split():
189-
if part.startswith("v1,"):
190-
signatures.append(part[3:])
191-
else:
192-
signatures.append(part)
193-
194-
# Decode the secret if it starts with whsec_
195-
if secret.startswith("whsec_"):
196-
decoded_secret = base64.b64decode(secret[6:])
197-
else:
198-
decoded_secret = secret.encode()
199-
200-
body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
201-
202-
# Prepare the signed payload (OpenAI uses webhookId.timestamp.payload format)
203-
signed_payload = f"{webhook_id}.{timestamp}.{body}"
204-
expected_signature = base64.b64encode(
205-
hmac.new(decoded_secret, signed_payload.encode(), hashlib.sha256).digest()
206-
).decode()
207-
208-
# Accept if any signature matches
209-
if not any(hmac.compare_digest(expected_signature, sig) for sig in signatures):
119+
if not _webhook_signature_matches(payload, headers, secret=secret, tolerance=tolerance):
210120
raise InvalidWebhookSignatureError("The given webhook signature does not match the expected signature")
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
from __future__ import annotations
2+
3+
import hmac
4+
import base64
5+
import hashlib
6+
import binascii
7+
from typing import Iterator, cast
8+
from unittest import mock
9+
10+
import pytest
11+
12+
import openai
13+
from openai._exceptions import InvalidWebhookSignatureError
14+
from openai.lib._webhooks import webhook_signature_matches
15+
from openai.resources.webhooks.webhooks import Webhooks, AsyncWebhooks
16+
17+
NOW = 1_750_000_000
18+
PAYLOAD = '{"synthetic": "café"}'
19+
WEBHOOK_ID = "evt_synthetic"
20+
RAW_SECRET = "synthetic-webhook-secret"
21+
PREFIXED_SECRET = "whsec_" + base64.b64encode(RAW_SECRET.encode()).decode()
22+
23+
24+
def signed_headers(
25+
payload: str | bytes = PAYLOAD,
26+
*,
27+
timestamp: str = str(NOW),
28+
secret: bytes = RAW_SECRET.encode(),
29+
) -> dict[str, str]:
30+
body = payload.encode() if isinstance(payload, str) else payload
31+
signed = f"{WEBHOOK_ID}.{timestamp}.".encode() + body
32+
signature = base64.b64encode(hmac.new(secret, signed, hashlib.sha256).digest()).decode()
33+
return {
34+
"webhook-id": WEBHOOK_ID,
35+
"webhook-timestamp": timestamp,
36+
"webhook-signature": f"v1,{signature}",
37+
}
38+
39+
40+
@pytest.fixture(autouse=True)
41+
def frozen_time() -> Iterator[None]:
42+
with mock.patch("time.time", return_value=NOW):
43+
yield
44+
45+
46+
@pytest.fixture(params=["sync", "async"])
47+
def webhook_resource(request: pytest.FixtureRequest) -> Webhooks | AsyncWebhooks:
48+
client = mock.Mock()
49+
client.webhook_secret = None
50+
if request.param == "sync":
51+
return Webhooks(cast(openai.OpenAI, client))
52+
return AsyncWebhooks(cast(openai.AsyncOpenAI, client))
53+
54+
55+
@pytest.mark.parametrize(
56+
("secret", "key"),
57+
[(RAW_SECRET, RAW_SECRET.encode()), (PREFIXED_SECRET, RAW_SECRET.encode()), ("", b"")],
58+
ids=["raw", "prefixed", "empty"],
59+
)
60+
@pytest.mark.parametrize("payload", [PAYLOAD, PAYLOAD.encode()], ids=["text", "bytes"])
61+
@pytest.mark.parametrize("signature_form", ["prefixed", "bare", "multiple"])
62+
def test_signature_forms_and_secret_encodings(
63+
webhook_resource: Webhooks | AsyncWebhooks,
64+
secret: str,
65+
key: bytes,
66+
payload: str | bytes,
67+
signature_form: str,
68+
) -> None:
69+
headers = signed_headers(payload, secret=key)
70+
signature = headers["webhook-signature"][3:]
71+
if signature_form == "bare":
72+
headers["webhook-signature"] = signature
73+
elif signature_form == "multiple":
74+
headers["webhook-signature"] = f"v1,invalid\t{signature} v1,also-invalid"
75+
headers = {name.upper(): value for name, value in headers.items()}
76+
77+
assert webhook_signature_matches(payload, headers, secret=secret, tolerance=300)
78+
assert webhook_resource.verify_signature(payload, headers, secret=secret) is None
79+
80+
81+
@pytest.mark.parametrize(
82+
("delta", "tolerance", "error"),
83+
[
84+
(0, 0, None),
85+
(-300, 300, None),
86+
(300, 300, None),
87+
(-301, 300, "Webhook timestamp is too old"),
88+
(301, 300, "Webhook timestamp is too new"),
89+
(-1, 0, "Webhook timestamp is too old"),
90+
(1, 0, "Webhook timestamp is too new"),
91+
(0, -1, "Webhook timestamp is too old"),
92+
],
93+
)
94+
def test_replay_window_boundaries(
95+
webhook_resource: Webhooks | AsyncWebhooks,
96+
delta: int,
97+
tolerance: int,
98+
error: str | None,
99+
) -> None:
100+
headers = signed_headers(timestamp=str(NOW + delta))
101+
if error is None:
102+
webhook_resource.verify_signature(PAYLOAD, headers, secret=RAW_SECRET, tolerance=tolerance)
103+
else:
104+
with pytest.raises(InvalidWebhookSignatureError, match=error) as caught:
105+
webhook_resource.verify_signature(PAYLOAD, headers, secret=RAW_SECRET, tolerance=tolerance)
106+
assert caught.value.__cause__ is None
107+
assert caught.value.__suppress_context__
108+
109+
110+
@pytest.mark.parametrize("timestamp", [f"0{NOW}", f"+{NOW}", f" {NOW} "])
111+
def test_signature_uses_original_timestamp_text(webhook_resource: Webhooks | AsyncWebhooks, timestamp: str) -> None:
112+
webhook_resource.verify_signature(PAYLOAD, signed_headers(timestamp=timestamp), secret=RAW_SECRET)
113+
114+
115+
@pytest.mark.parametrize("timestamp", ["", "not-a-timestamp", "1.5"])
116+
def test_invalid_timestamp_exception(webhook_resource: Webhooks | AsyncWebhooks, timestamp: str) -> None:
117+
with pytest.raises(InvalidWebhookSignatureError, match="Invalid webhook timestamp format") as caught:
118+
webhook_resource.verify_signature(PAYLOAD, signed_headers(timestamp=timestamp), secret=RAW_SECRET)
119+
assert caught.value.__cause__ is None
120+
assert caught.value.__suppress_context__
121+
122+
123+
@pytest.mark.parametrize(
124+
("headers", "missing"),
125+
[
126+
({}, "webhook-signature"),
127+
({"webhook-signature": "invalid"}, "webhook-timestamp"),
128+
({"webhook-signature": "invalid", "webhook-timestamp": str(NOW)}, "webhook-id"),
129+
],
130+
)
131+
def test_required_header_order(
132+
webhook_resource: Webhooks | AsyncWebhooks, headers: dict[str, str], missing: str
133+
) -> None:
134+
with pytest.raises(ValueError, match=f"Could not find {missing} header"):
135+
webhook_resource.verify_signature(PAYLOAD, headers, secret=RAW_SECRET)
136+
137+
138+
def test_client_secret_fallback_preserves_explicit_empty_secret(
139+
webhook_resource: Webhooks | AsyncWebhooks,
140+
) -> None:
141+
webhook_resource._client.webhook_secret = PREFIXED_SECRET
142+
webhook_resource.verify_signature(PAYLOAD, signed_headers())
143+
webhook_resource.verify_signature(PAYLOAD, signed_headers(secret=b""), secret="")
144+
145+
146+
def test_missing_secret_preserves_wrapper_exception_chaining(
147+
webhook_resource: Webhooks | AsyncWebhooks,
148+
) -> None:
149+
previous = RuntimeError("synthetic prior failure")
150+
try:
151+
raise previous
152+
except RuntimeError:
153+
with pytest.raises(ValueError, match="The webhook secret must either be set") as caught:
154+
webhook_resource.verify_signature(PAYLOAD, {})
155+
156+
assert caught.value.__context__ is previous
157+
assert caught.value.__cause__ is None
158+
assert caught.value.__suppress_context__ is isinstance(webhook_resource, AsyncWebhooks)
159+
160+
161+
def test_mismatch_preserves_wrapper_exception_chaining(
162+
webhook_resource: Webhooks | AsyncWebhooks,
163+
) -> None:
164+
headers = signed_headers()
165+
headers["webhook-signature"] = "v1,synthetic-invalid-signature"
166+
previous = RuntimeError("synthetic prior failure")
167+
try:
168+
raise previous
169+
except RuntimeError:
170+
with pytest.raises(InvalidWebhookSignatureError, match="does not match the expected signature") as caught:
171+
webhook_resource.verify_signature(PAYLOAD, headers, secret=RAW_SECRET)
172+
173+
assert caught.value.__context__ is previous
174+
assert caught.value.__cause__ is None
175+
assert caught.value.__suppress_context__ is (not isinstance(webhook_resource, AsyncWebhooks))
176+
assert RAW_SECRET not in str(caught.value)
177+
assert PAYLOAD not in str(caught.value)
178+
assert headers["webhook-signature"] not in str(caught.value)
179+
180+
181+
def test_malformed_secret_keeps_base64_error(webhook_resource: Webhooks | AsyncWebhooks) -> None:
182+
with pytest.raises(binascii.Error):
183+
webhook_resource.verify_signature(PAYLOAD, signed_headers(), secret="whsec_a")
184+
185+
186+
def test_invalid_utf8_payload_keeps_decode_error(webhook_resource: Webhooks | AsyncWebhooks) -> None:
187+
with pytest.raises(UnicodeDecodeError):
188+
webhook_resource.verify_signature(b"\xff", signed_headers(), secret=RAW_SECRET)
189+
190+
191+
def test_non_ascii_signature_keeps_compare_error(webhook_resource: Webhooks | AsyncWebhooks) -> None:
192+
headers = signed_headers()
193+
headers["webhook-signature"] = "v1,é"
194+
with pytest.raises(TypeError, match="non-ASCII"):
195+
webhook_resource.verify_signature(PAYLOAD, headers, secret=RAW_SECRET)
196+
197+
198+
def test_constant_time_comparison_order(webhook_resource: Webhooks | AsyncWebhooks) -> None:
199+
headers = signed_headers()
200+
expected = headers["webhook-signature"][3:]
201+
headers["webhook-signature"] = f"v1,invalid v1,{expected} v1,unused"
202+
with mock.patch("openai.lib._webhooks.hmac.compare_digest", wraps=hmac.compare_digest) as compare:
203+
webhook_resource.verify_signature(PAYLOAD, headers, secret=RAW_SECRET)
204+
assert compare.call_args_list == [mock.call(expected, "invalid"), mock.call(expected, expected)]
205+
206+
207+
@pytest.mark.parametrize("signature", ["", "v1,invalid", "invalid v1,also-invalid"])
208+
def test_helper_returns_false_for_mismatch(signature: str) -> None:
209+
headers = signed_headers()
210+
headers["webhook-signature"] = signature
211+
assert not webhook_signature_matches(PAYLOAD, headers, secret=RAW_SECRET, tolerance=300)

0 commit comments

Comments
 (0)