diff --git a/README.md b/README.md index e8a1c6a..7c80a4d 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,36 @@ Unconfirmed bookings are cancelled after 24 hours. `checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl` points to the same page. Trusted apps keep checking out directly. +### Webhooks + +Anikeen ID sends `order.*` and `subscription.*` events to the webhook endpoints of your app (set up +in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request +with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days. + +```php +use Anikeen\Id\Exceptions\SignatureVerificationException; +use Anikeen\Id\Webhooks\Webhook; + +Route::post('webhooks/anikeen', function (Request $request) { + try { + $event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret')); + } catch (SignatureVerificationException) { + abort(400); + } + + match ($event['type']) { + 'order.completed' => Shop::fulfil($event['data']['object']['id']), + 'subscription.cancelled' => Panel::suspend($event['data']['object']['id']), + default => null, + }; + + return response()->noContent(); +})->withoutMiddleware(VerifyCsrfToken::class); +``` + +Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and +reload the object through the API when the current state matters. + ### Change the default access token / refresh token field name If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method: diff --git a/README.stub b/README.stub index 9223695..6fdf071 100644 --- a/README.stub +++ b/README.stub @@ -183,6 +183,36 @@ Unconfirmed bookings are cancelled after 24 hours. `checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl` points to the same page. Trusted apps keep checking out directly. +### Webhooks + +Anikeen ID sends `order.*` and `subscription.*` events to the webhook endpoints of your app (set up +in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request +with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days. + +```php +use Anikeen\Id\Exceptions\SignatureVerificationException; +use Anikeen\Id\Webhooks\Webhook; + +Route::post('webhooks/anikeen', function (Request $request) { + try { + $event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret')); + } catch (SignatureVerificationException) { + abort(400); + } + + match ($event['type']) { + 'order.completed' => Shop::fulfil($event['data']['object']['id']), + 'subscription.cancelled' => Panel::suspend($event['data']['object']['id']), + default => null, + }; + + return response()->noContent(); +})->withoutMiddleware(VerifyCsrfToken::class); +``` + +Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and +reload the object through the API when the current state matters. + ### Change the default access token / refresh token field name If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method: diff --git a/src/Id/Exceptions/SignatureVerificationException.php b/src/Id/Exceptions/SignatureVerificationException.php new file mode 100644 index 0000000..203b453 --- /dev/null +++ b/src/Id/Exceptions/SignatureVerificationException.php @@ -0,0 +1,13 @@ +,v1=.">`. + */ +final class Webhook +{ + /** Requests older (or newer) than this many seconds are rejected to prevent replays. */ + public const DEFAULT_TOLERANCE = 300; + + /** + * Verifies the signature and returns the event: ['id', 'type', 'created', 'data' => ['object' => …]]. + * + * @param string $payload the raw request body (not re-encoded JSON) + * @param string|null $signatureHeader the Anikeen-Signature header + * + * @throws SignatureVerificationException + */ + public static function constructEvent(string $payload, ?string $signatureHeader, string $secret, int $tolerance = self::DEFAULT_TOLERANCE): array + { + self::verify($payload, $signatureHeader, $secret, $tolerance); + + $event = json_decode($payload, true); + + if (!is_array($event) || !isset($event['id'], $event['type'])) { + throw new SignatureVerificationException('The webhook payload is not a valid event.'); + } + + return $event; + } + + /** + * @throws SignatureVerificationException + */ + public static function verify(string $payload, ?string $signatureHeader, string $secret, int $tolerance = self::DEFAULT_TOLERANCE): void + { + $timestamp = null; + $signatures = []; + + foreach (explode(',', (string) $signatureHeader) as $part) { + [$key, $value] = array_pad(explode('=', trim($part), 2), 2, null); + + if ($key === 't' && ctype_digit((string) $value)) { + $timestamp = (int) $value; + } elseif ($key === 'v1' && $value) { + $signatures[] = $value; + } + } + + if ($timestamp === null || $signatures === []) { + throw new SignatureVerificationException('The Anikeen-Signature header is missing or malformed.'); + } + + $expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret); + + if (!array_filter($signatures, fn (string $signature) => hash_equals($expected, $signature))) { + throw new SignatureVerificationException('The webhook signature does not match. Check the signing secret.'); + } + + if ($tolerance > 0 && abs(time() - $timestamp) > $tolerance) { + throw new SignatureVerificationException('The webhook timestamp is outside the tolerance.'); + } + } +} diff --git a/tests/Id/WebhookTest.php b/tests/Id/WebhookTest.php new file mode 100644 index 0000000..ab0bb88 --- /dev/null +++ b/tests/Id/WebhookTest.php @@ -0,0 +1,56 @@ +payload}", $secret ?? $this->secret); + } + + public function test_a_signed_event_is_returned(): void + { + $event = Webhook::constructEvent($this->payload, $this->header(), $this->secret); + + $this->assertSame('order.completed', $event['type']); + $this->assertSame('01JORD', $event['data']['object']['id']); + } + + public function test_a_wrong_secret_is_rejected(): void + { + $this->expectException(SignatureVerificationException::class); + + Webhook::constructEvent($this->payload, $this->header(secret: 'whsec_other'), $this->secret); + } + + public function test_a_changed_body_is_rejected(): void + { + $this->expectException(SignatureVerificationException::class); + + Webhook::constructEvent(str_replace('01JORD', '01JXXX', $this->payload), $this->header(), $this->secret); + } + + public function test_old_requests_are_rejected(): void + { + $this->expectException(SignatureVerificationException::class); + + Webhook::constructEvent($this->payload, $this->header(time() - 600), $this->secret); + } + + public function test_a_missing_header_is_rejected(): void + { + $this->expectException(SignatureVerificationException::class); + + Webhook::constructEvent($this->payload, null, $this->secret); + } +}