<?php
declare(strict_types=1);
namespace GNexus\GAuth\Webhook;
use GNexus\GAuth\Config\GAuthConfig;
use GNexus\GAuth\Contract\ClockInterface;
use GNexus\GAuth\Contract\WebhookVerifierInterface;
use GNexus\GAuth\DTO\VerifiedWebhook;
use GNexus\GAuth\Exception\WebhookVerificationException;
use GNexus\GAuth\Support\SystemClock;
final readonly class HmacWebhookVerifier implements WebhookVerifierInterface
{
private ClockInterface $clock;
public function __construct(
private GAuthConfig $config,
?ClockInterface $clock = null,
) {
$this->clock = $clock ?? new SystemClock();
}
public function verify(string $rawBody, array $headers, string $secret): VerifiedWebhook
{
$normalized = $this->normalizeHeaders($headers);
foreach (['x-gnexus-event-id', 'x-gnexus-event-type', 'x-gnexus-event-timestamp', 'x-gnexus-signature'] as $requiredHeader) {
if (! isset($normalized[$requiredHeader]) || $normalized[$requiredHeader] === '') {
throw new WebhookVerificationException(sprintf('Missing webhook header: %s.', $requiredHeader));
}
}
$signature = $this->parseSignatureHeader($normalized['x-gnexus-signature']);
$timestamp = $signature['timestamp'];
$expectedSignature = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (! hash_equals($expectedSignature, $signature['hash'])) {
throw new WebhookVerificationException('Invalid webhook signature.');
}
$tolerance = $this->config->webhookToleranceSeconds();
if ($tolerance > 0 && abs($this->clock->now()->getTimestamp() - $timestamp) > $tolerance) {
throw new WebhookVerificationException('Webhook timestamp is outside the allowed tolerance window.');
}
return new VerifiedWebhook(
rawBody: $rawBody,
normalizedHeaders: $normalized,
signatureId: $normalized['x-gnexus-event-id'],
verifiedAt: $this->clock->now(),
);
}
/**
* @param array<string, mixed> $headers
* @return array<string, string>
*/
private function normalizeHeaders(array $headers): array
{
$normalized = [];
foreach ($headers as $name => $value) {
$normalizedName = strtolower((string) $name);
$normalizedName = str_replace('_', '-', $normalizedName);
if (is_array($value)) {
$value = $value[0] ?? '';
}
$normalized[$normalizedName] = trim((string) $value);
}
return $normalized;
}
/**
* @return array{timestamp:int, hash:string}
*/
private function parseSignatureHeader(string $header): array
{
$parts = [];
foreach (explode(',', $header) as $chunk) {
[$key, $value] = array_pad(explode('=', trim($chunk), 2), 2, null);
if ($key !== null && $value !== null) {
$parts[$key] = $value;
}
}
if (! isset($parts['t'], $parts['v1'])) {
throw new WebhookVerificationException('Malformed webhook signature header.');
}
if (! ctype_digit($parts['t'])) {
throw new WebhookVerificationException('Webhook timestamp must be numeric.');
}
return [
'timestamp' => (int) $parts['t'],
'hash' => strtolower($parts['v1']),
];
}
}