diff --git a/README.md b/README.md index 3e519b1b..bf7f137c 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ If you or your business relies on this package, it's important to support the de - [Meta Information](#meta-information) - [Troubleshooting](#troubleshooting) - [Testing](#testing) +- [Webhooks][#webhooks] - [Services](#services) - [Azure](#azure) @@ -3114,6 +3115,25 @@ $completion = $client->completions()->create([ ]); ``` +## Webhooks + +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. + +```php +use OpenAI\Webhooks\SignatureVerifier; +use OpenAI\Exceptions\WebhookVerificationException; + +$verifier = new SignatureVerifier('whsec_{your-webhook-signing-secret}'); + +try { + $verifier->verify($incomingRequest); + + // The request is verified +} catch (WebhookVerificationException $exception) { + // The request could not be verified +} +``` + ## Services ### Azure diff --git a/src/Enums/Webhooks/WebhookEvent.php b/src/Enums/Webhooks/WebhookEvent.php new file mode 100644 index 00000000..31d7b358 --- /dev/null +++ b/src/Enums/Webhooks/WebhookEvent.php @@ -0,0 +1,24 @@ +secret = base64_decode($secret, true) + ?: throw new UnexpectedValueException('Invalid secret format'); + } + + /** + * @throws WebhookVerificationException|RuntimeException + */ + public function verify(RequestInterface $request): void + { + $body = $request->getBody(); + $payload = $body->getContents(); + $body->rewind(); + + $this->verifySignature($payload, [ + 'webhook-id' => trim($request->getHeaderLine('webhook-id')) ?: null, + 'webhook-timestamp' => trim($request->getHeaderLine('webhook-timestamp')) ?: null, + 'webhook-signature' => trim($request->getHeaderLine('webhook-signature')) ?: null, + ]); + } + + /** + * @param array{webhook-id: ?non-falsy-string, webhook-timestamp: ?non-falsy-string, webhook-signature: ?non-falsy-string} $headers + * + * @throws WebhookVerificationException + */ + final protected function verifySignature(string $payload, array $headers): void + { + if (! isset($headers['webhook-id'], $headers['webhook-timestamp'], $headers['webhook-signature'])) { + throw WebhookVerificationException::missingRequiredHeader(); + } + + [ + 'webhook-id' => $messageId, + 'webhook-timestamp' => $messageTimestamp, + 'webhook-signature' => $messageSignature, + ] = $headers; + $timestamp = $this->verifyTimestamp($messageTimestamp); + $signature = $this->sign($messageId, $timestamp, $payload); + [, $expectedSignature] = explode(',', $signature, 2); + $passedSignatures = explode(' ', $messageSignature); + + foreach ($passedSignatures as $versionedSignature) { + [$version, $passedSignature] = explode(',', $versionedSignature, 2); + + if (strcmp($version, 'v1') !== 0) { + continue; + } + + if (hash_equals($expectedSignature, $passedSignature)) { + return; + } + } + + throw WebhookVerificationException::noMatchingSignature(); + } + + /** + * @throws WebhookVerificationException + * + * @internal + */ + final public function sign(string $messageId, DateTimeInterface|int $timestamp, string $payload): string + { + $timestamp = match (true) { + $timestamp instanceof DateTimeInterface => $timestamp->getTimestamp(), + is_int($timestamp) && $timestamp > 0 => $timestamp, + default => throw WebhookVerificationException::invalidTimestamp(), + }; + + $hash = hash_hmac( + 'sha256', + implode('.', [$messageId, $timestamp, $payload]), + $this->secret, + ); + $signature = base64_encode(pack('H*', $hash)); + + return 'v1,'.$signature; + } + + /** + * @throws WebhookVerificationException + */ + protected function verifyTimestamp(string $timestampHeader): int + { + $now = time(); + $timestamp = (int) $timestampHeader; + + if ($timestamp < ($now - $this->tolerance)) { + throw WebhookVerificationException::timestampMismatch(); + } + + if ($timestamp > ($now + $this->tolerance)) { + throw WebhookVerificationException::timestampMismatch(); + } + + return $timestamp; + } +} diff --git a/tests/Webhooks/WebhookSignatureVerifier.php b/tests/Webhooks/WebhookSignatureVerifier.php new file mode 100644 index 00000000..b740167f --- /dev/null +++ b/tests/Webhooks/WebhookSignatureVerifier.php @@ -0,0 +1,236 @@ +sign($messageId, $timestamp, $payload); + $request = createWebhookRequest([ + 'webhook-id' => $messageId, + 'webhook-timestamp' => $timestamp, + 'webhook-signature' => $signature, + ], $payload); + + expect(static fn () => $verifier->verify($request)) + ->not->toThrow( + WebhookVerificationException::class, + message: 'Valid signature should not cause an exception', + ); +}); + +it('should bail on invalid signatures', function () { + $secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; + $messageId = 'msg_2KWPBgLlAfxdpx2AI54pPJ85f4W'; + $timestamp = time(); + $payload = '{"foo":"bar"}'; + + $verifier = new WebhookSignatureVerifier($secret); + $request = createWebhookRequest([ + 'webhook-id' => $messageId, + 'webhook-timestamp' => $timestamp, + 'webhook-signature' => 'v1,dawfeoifkpqwoekfpqoekf', + ], $payload); + + expect(static fn () => $verifier->verify($request)) + ->toThrow( + WebhookVerificationException::class, + 'No matching signature found', + 'Invalid signature should cause an exception', + ); +}); + +it('should bail on missing webhook-id header', function () { + $secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; + $messageId = 'msg_2KWPBgLlAfxdpx2AI54pPJ85f4W'; + $timestamp = time(); + $payload = '{"foo":"bar"}'; + + $verifier = new WebhookSignatureVerifier($secret); + $signature = $verifier->sign($messageId, $timestamp, $payload); + $request = createWebhookRequest([ + 'webhook-timestamp' => $timestamp, + 'webhook-signature' => $signature, + ], $payload); + + expect(static fn () => $verifier->verify($request)) + ->toThrow( + WebhookVerificationException::class, + 'Missing required header', + 'Missing message ID header should cause an exception', + ); +}); + +it('should bail on missing webhook-timestamp header', function () { + $secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; + $messageId = 'msg_2KWPBgLlAfxdpx2AI54pPJ85f4W'; + $timestamp = time(); + $payload = '{"foo":"bar"}'; + + $verifier = new WebhookSignatureVerifier($secret); + $signature = $verifier->sign($messageId, $timestamp, $payload); + $request = createWebhookRequest([ + 'webhook-id' => $messageId, + 'webhook-signature' => $signature, + ], $payload); + + expect(static fn () => $verifier->verify($request)) + ->toThrow( + WebhookVerificationException::class, + 'Missing required header', + 'Missing timestamp header should cause an exception', + ); +}); + +it('should bail on missing webhook-signature header', function () { + $secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; + $messageId = 'msg_2KWPBgLlAfxdpx2AI54pPJ85f4W'; + $timestamp = time(); + $payload = '{"foo":"bar"}'; + + $verifier = new WebhookSignatureVerifier($secret); + $request = createWebhookRequest([ + 'webhook-id' => $messageId, + 'webhook-timestamp' => $timestamp, + ], $payload); + + expect(static fn () => $verifier->verify($request)) + ->toThrow( + WebhookVerificationException::class, + 'Missing required header', + 'Missing signature header should cause an exception', + ); +}); + +it('should bail on past timestamp', function () { + $secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; + $messageId = 'msg_2KWPBgLlAfxdpx2AI54pPJ85f4W'; + $timestamp = time() - 200; + $payload = '{"foo":"bar"}'; + + $verifier = new WebhookSignatureVerifier($secret, 100); + $signature = $verifier->sign($messageId, $timestamp, $payload); + $request = createWebhookRequest([ + 'webhook-id' => $messageId, + 'webhook-timestamp' => $timestamp, + 'webhook-signature' => $signature, + ], $payload); + + expect(static fn () => $verifier->verify($request)) + ->toThrow( + WebhookVerificationException::class, + 'Message timestamp outside tolerance window', + 'Too old timestamp should cause an exception', + ); +}); + +it('should bail on future timestamp', function () { + $secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; + $messageId = 'msg_2KWPBgLlAfxdpx2AI54pPJ85f4W'; + $timestamp = time() + 200; + $payload = '{"foo":"bar"}'; + + $verifier = new WebhookSignatureVerifier($secret, 100); + $signature = $verifier->sign($messageId, $timestamp, $payload); + $request = createWebhookRequest([ + 'webhook-id' => $messageId, + 'webhook-timestamp' => $timestamp, + 'webhook-signature' => $signature, + ], $payload); + + expect(static fn () => $verifier->verify($request)) + ->toThrow( + WebhookVerificationException::class, + 'Message timestamp outside tolerance window', + 'Too new timestamp should cause an exception', + ); +}); + +it('should handle multiple signatures', function () { + $secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; + $messageId = 'msg_2KWPBgLlAfxdpx2AI54pPJ85f4W'; + $timestamp = time(); + $payload = '{"foo":"bar"}'; + + $verifier = new WebhookSignatureVerifier($secret); + $signature = $verifier->sign($messageId, $timestamp, $payload); + $request = createWebhookRequest([ + 'webhook-id' => $messageId, + 'webhook-timestamp' => $timestamp, + 'webhook-signature' => implode(' ', [ + 'v1,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=', + 'v2,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=', + $signature, + 'v1a,hnO3f9T8Ytu9HwrXslvumlUpqtNVqkhqw/enGzPCXe5BdqzCInXqYXFymVJaA7AZdpXwVLPo3mNl8EM+m7TBAg==', + 'v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=', + ]), + ], $payload); + + expect(static fn () => $verifier->verify($request)) + ->not->toThrow( + WebhookVerificationException::class, + message: 'One of the signatures should be accepted', + ); +}); + +it('should handle secret prefixes', function () { + $secret = 'MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; + $messageId = 'msg_2KWPBgLlAfxdpx2AI54pPJ85f4W'; + $timestamp = time(); + $payload = '{"foo":"bar"}'; + + $defaultVerifier = new WebhookSignatureVerifier($secret); + $prefixVerifier = new WebhookSignatureVerifier('whsec_'.$secret); + $customPrefixVerifier = new WebhookSignatureVerifier('foobar_'.$secret, secretPrefix: 'foobar_'); + $signature = $defaultVerifier->sign($messageId, $timestamp, $payload); + $request = createWebhookRequest([ + 'webhook-id' => $messageId, + 'webhook-timestamp' => $timestamp, + 'webhook-signature' => $signature, + ], $payload); + + expect(static fn () => $defaultVerifier->verify($request)) + ->not + ->toThrow( + WebhookVerificationException::class, + message: 'Verifier configured without prefix should accept the signature', + ) + ->and(static fn () => $prefixVerifier->verify($request)) + ->not + ->toThrow( + WebhookVerificationException::class, + message: 'Verifier configured with prefix should accept the signature', + ) + ->and(static fn () => $customPrefixVerifier->verify($request)) + ->not + ->toThrow( + WebhookVerificationException::class, + message: 'Verifier configured with custom prefix should accept the signature', + ); +}); + +/** + * @throws InvalidArgumentException + */ +function createWebhookRequest(array $headers, ?string $payload = null): ServerRequestInterface +{ + $factory = new Psr17Factory; + $request = $factory->createServerRequest('POST', '/webhook'); + + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + if ($payload !== null) { + $request = $request->withBody($factory->createStream($payload)); + } + + return $request; +}