Skip to content

WebhookVerifyMiddleware: inbound PSR-15 (signature + timestamp window + dedupe) #186

@tonydspaniard

Description

@tonydspaniard

Part of #184. Depends on #185 (storage + signers).

Goal

Add Altair\Webhooks\Middleware\WebhookVerifyMiddleware \xe2\x80\x94 a PSR-15 middleware that handles the inbound side of the webhook contract. Reads the signature header, verifies it against the configured signer + secret, enforces a timestamp window for replay protection, dedupes by event id, and either short-circuits (already processed) or passes the verified payload to the handler.

Why

The signers + dedupe store (#185) are the foundation; this middleware is where they become operational. Mirrors the role IdempotencyKeyMiddleware (#173) plays in the idempotency epic.

Behaviour matrix

Situation Response
Signature header absent 401 Unauthorized with {error} envelope.
Signature mismatch 401 Unauthorized.
Timestamp header absent (mode=strict) 400 Bad Request.
Timestamp outside window 400 Bad Request with outside replay window.
Event id seen within dedupe TTL 200 OK with empty body + Webhook-Replayed: true header. (Idempotent absorb — never 409; the caller has already been told once that delivery succeeded.)
Fresh event Claim event id; pass through; on success mark delivered.
Handler throws Release the dedupe claim; re-throw.

Header names are configurable (constructor params); defaults match Stripe / GitHub conventions:

  • X-Signature \xe2\x80\x94 carries the signature (format depends on signer; HMAC-SHA256 commonly uses t=<ts>,v1=<hex> or just <hex>).
  • X-Timestamp \xe2\x80\x94 epoch seconds the event was emitted.
  • X-Event-Id \xe2\x80\x94 stable identifier for dedupe; when absent, the middleware hashes body || timestamp to synthesise one.

Companion: ActionAwareWebhookVerifyMiddleware

Add the auto-wiring variant in the same PR (mirrors ActionAwareIdempotencyMiddleware from #182):

ActionAwareWebhookVerifyMiddleware reads the resolved Action via altair:http:action, looks for a static webhook() accessor (emitted by the scaffolder when the spec carries webhook: { direction: in }), and configures WebhookVerifyMiddleware per request from the policy. Pass-through when no Action / no accessor / wrong direction.

Shape

final readonly class WebhookVerifyMiddleware implements MiddlewareInterface
{
    public function __construct(
        private SignerInterface $signer,
        private SecretResolverInterface $secrets,
        private InboundDeduplicatorInterface $deduplicator,
        private ResponseFactoryInterface $responseFactory,
        private StreamFactoryInterface $streamFactory,
        private string $secretName,
        private int $dedupeTtlSeconds = 3600,
        private int $timestampWindowSeconds = 300,
        private string $signatureHeader = 'X-Signature',
        private string $timestampHeader = 'X-Timestamp',
        private string $eventIdHeader = 'X-Event-Id',
    ) {}

    public function process(ServerRequest, RequestHandler): Response { ... }
}

Acceptance criteria

  • WebhookVerifyMiddleware implements PSR-15 with the listed constructor parameters
  • Verifies HMAC-SHA256 signatures using hash_equals (verified by a corrupted-signature regression test)
  • Timestamp window enforced both directions (past and future); future-skewed timestamps beyond window also reject
  • Event id dedup is atomic (concurrent identical deliveries see exactly one handler invocation)
  • Replay returns 200 OK + Webhook-Replayed: true header (not 409 \xe2\x80\x94 the caller's contract is "we processed this once")
  • Handler-throw path releases the dedupe claim
  • ActionAwareWebhookVerifyMiddleware companion class lands alongside; constructor-injectable attribute name matching altair:http:action
  • Body is rebuilt after reading so downstream handlers see the same content from position 0 (regression guard, same pattern as IdempotencyKeyMiddleware)
  • Tests use InMemoryDeduplicator exclusively; no Redis dependency in the test suite
  • 80%+ coverage on the new namespace

Out of scope

  • The outbound dispatcher \xe2\x80\x94 separate issue
  • The webhook: spec block \xe2\x80\x94 separate issue
  • Replay attack protection beyond timestamp window + dedupe (e.g. nonce ratchets) \xe2\x80\x94 cryptographically unnecessary given the two checks combined

Notes

The signature format is intentionally loose at the contract level: SignerInterface::verify() decides how to parse the header. HmacSha256Signer accepts either bare hex MAC or Stripe's t=<ts>,v1=<hex> form (the parser extracts the v1= component). This keeps the middleware signer-agnostic and lets new signer implementations define their own header format.

Failure responses use {error: "..."} envelopes matching the idempotency package convention. Don't leak which check failed in the error body \xe2\x80\x94 same message ("webhook signature verification failed") for missing-header / wrong-signature / wrong-secret all three. Distinguishing them in the error body helps attackers; logging the distinction server-side is fine.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions