Skip to content

Commit 5092eca

Browse files
authored
feat(core): Add webhook signature verifier (#722)
1 parent a3cb384 commit 5092eca

File tree

5 files changed

+437
-0
lines changed

5 files changed

+437
-0
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ If you or your business relies on this package, it's important to support the de
5757
- [Meta Information](#meta-information)
5858
- [Troubleshooting](#troubleshooting)
5959
- [Testing](#testing)
60+
- [Webhooks][#webhooks]
6061
- [Services](#services)
6162
- [Azure](#azure)
6263

@@ -3114,6 +3115,25 @@ $completion = $client->completions()->create([
31143115
]);
31153116
```
31163117

3118+
## Webhooks
3119+
3120+
The package includes a signature verifier for OpenAI webhooks. To verify the signature of incoming webhook requests, you can use the `OpenAI\Webhooks\SignatureVerifier` class.
3121+
3122+
```php
3123+
use OpenAI\Webhooks\SignatureVerifier;
3124+
use OpenAI\Exceptions\WebhookVerificationException;
3125+
3126+
$verifier = new SignatureVerifier('whsec_{your-webhook-signing-secret}');
3127+
3128+
try {
3129+
$verifier->verify($incomingRequest);
3130+
3131+
// The request is verified
3132+
} catch (WebhookVerificationException $exception) {
3133+
// The request could not be verified
3134+
}
3135+
```
3136+
31173137
## Services
31183138

31193139
### Azure
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace OpenAI\Enums\Webhooks;
4+
5+
enum WebhookEvent: string
6+
{
7+
case BatchCancelled = 'batch.cancelled';
8+
case BatchCompleted = 'batch.completed';
9+
case BatchExpired = 'batch.expired';
10+
case BatchFailed = 'batch.failed';
11+
case EvalRunCancelled = 'eval.run.canceled';
12+
case EvalRunFailed = 'eval.run.failed';
13+
case EvalRunSucceeded = 'eval.run.succeeded';
14+
case FineTuningJobCancelled = 'fine_tuning.job.cancelled';
15+
case FineTuningJobFailed = 'fine_tuning.job.failed';
16+
case FineTuningJobSucceeded = 'fine_tuning.job.succeeded';
17+
case RealtimeCallIncoming = 'realtime.call.incoming';
18+
case ResponseCancelled = 'response.cancelled';
19+
case ResponseCompleted = 'response.completed';
20+
case ResponseFailed = 'response.failed';
21+
case ResponseIncomplete = 'response.incomplete';
22+
case VideoCompleted = 'video.completed';
23+
case VideoFailed = 'video.failed';
24+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace OpenAI\Exceptions;
4+
5+
use RuntimeException;
6+
7+
class WebhookVerificationException extends RuntimeException
8+
{
9+
protected function __construct(string $message, int $code = 0)
10+
{
11+
parent::__construct('Failed to verify webhook: '.$message, $code);
12+
}
13+
14+
public static function missingRequiredHeader(): self
15+
{
16+
return new self('Missing required header', 100);
17+
}
18+
19+
public static function noMatchingSignature(): self
20+
{
21+
return new self('No matching signature found', 200);
22+
}
23+
24+
public static function invalidTimestamp(): self
25+
{
26+
return new self('Invalid timestamp', 300);
27+
}
28+
29+
public static function timestampMismatch(): self
30+
{
31+
return new self('Message timestamp outside tolerance window', 301);
32+
}
33+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
namespace OpenAI\Webhooks;
4+
5+
use DateTimeInterface;
6+
use OpenAI\Exceptions\WebhookVerificationException;
7+
use Psr\Http\Message\RequestInterface;
8+
use RuntimeException;
9+
use UnexpectedValueException;
10+
11+
readonly class WebhookSignatureVerifier
12+
{
13+
private string $secret;
14+
15+
/**
16+
* @throws UnexpectedValueException
17+
*/
18+
public function __construct(
19+
string $secret,
20+
private int $tolerance = 300,
21+
string $secretPrefix = 'whsec_',
22+
) {
23+
if (str_starts_with($secret, $secretPrefix)) {
24+
$secret = substr($secret, strlen($secretPrefix));
25+
}
26+
27+
$this->secret = base64_decode($secret, true)
28+
?: throw new UnexpectedValueException('Invalid secret format');
29+
}
30+
31+
/**
32+
* @throws WebhookVerificationException|RuntimeException
33+
*/
34+
public function verify(RequestInterface $request): void
35+
{
36+
$body = $request->getBody();
37+
$payload = $body->getContents();
38+
$body->rewind();
39+
40+
$this->verifySignature($payload, [
41+
'webhook-id' => trim($request->getHeaderLine('webhook-id')) ?: null,
42+
'webhook-timestamp' => trim($request->getHeaderLine('webhook-timestamp')) ?: null,
43+
'webhook-signature' => trim($request->getHeaderLine('webhook-signature')) ?: null,
44+
]);
45+
}
46+
47+
/**
48+
* @param array{webhook-id: ?non-falsy-string, webhook-timestamp: ?non-falsy-string, webhook-signature: ?non-falsy-string} $headers
49+
*
50+
* @throws WebhookVerificationException
51+
*/
52+
final protected function verifySignature(string $payload, array $headers): void
53+
{
54+
if (! isset($headers['webhook-id'], $headers['webhook-timestamp'], $headers['webhook-signature'])) {
55+
throw WebhookVerificationException::missingRequiredHeader();
56+
}
57+
58+
[
59+
'webhook-id' => $messageId,
60+
'webhook-timestamp' => $messageTimestamp,
61+
'webhook-signature' => $messageSignature,
62+
] = $headers;
63+
$timestamp = $this->verifyTimestamp($messageTimestamp);
64+
$signature = $this->sign($messageId, $timestamp, $payload);
65+
[, $expectedSignature] = explode(',', $signature, 2);
66+
$passedSignatures = explode(' ', $messageSignature);
67+
68+
foreach ($passedSignatures as $versionedSignature) {
69+
[$version, $passedSignature] = explode(',', $versionedSignature, 2);
70+
71+
if (strcmp($version, 'v1') !== 0) {
72+
continue;
73+
}
74+
75+
if (hash_equals($expectedSignature, $passedSignature)) {
76+
return;
77+
}
78+
}
79+
80+
throw WebhookVerificationException::noMatchingSignature();
81+
}
82+
83+
/**
84+
* @throws WebhookVerificationException
85+
*
86+
* @internal
87+
*/
88+
final public function sign(string $messageId, DateTimeInterface|int $timestamp, string $payload): string
89+
{
90+
$timestamp = match (true) {
91+
$timestamp instanceof DateTimeInterface => $timestamp->getTimestamp(),
92+
is_int($timestamp) && $timestamp > 0 => $timestamp,
93+
default => throw WebhookVerificationException::invalidTimestamp(),
94+
};
95+
96+
$hash = hash_hmac(
97+
'sha256',
98+
implode('.', [$messageId, $timestamp, $payload]),
99+
$this->secret,
100+
);
101+
$signature = base64_encode(pack('H*', $hash));
102+
103+
return 'v1,'.$signature;
104+
}
105+
106+
/**
107+
* @throws WebhookVerificationException
108+
*/
109+
protected function verifyTimestamp(string $timestampHeader): int
110+
{
111+
$now = time();
112+
$timestamp = (int) $timestampHeader;
113+
114+
if ($timestamp < ($now - $this->tolerance)) {
115+
throw WebhookVerificationException::timestampMismatch();
116+
}
117+
118+
if ($timestamp > ($now + $this->tolerance)) {
119+
throw WebhookVerificationException::timestampMismatch();
120+
}
121+
122+
return $timestamp;
123+
}
124+
}

0 commit comments

Comments
 (0)