Module: Dinie::Webhooks

Defined in:
lib/dinie/runtime/webhooks.rb

Overview

‘Dinie::Webhooks.extract` — webhook verification + per-type deserialization, a module-function (RB11, architecture §8, §18.2). Implements the Standard Webhooks v1 contract Dinie inherited:

signed_payload = "{webhook-id}.{webhook-timestamp}.{body}"
expected       = base64(HMAC-SHA256(decode_secret(secret), signed_payload))
header         = "v1,<sig1> v1,<sig2>"   # space-separated, rotation-capable

‘extract` does verification AND deserialization in one call: it reads the three `webhook-*` headers (case-insensitive, symbol/string), enforces a bidirectional timestamp window (replay guard), decodes the secret (`whsec_` → base64), HMAC-SHA256s the signed payload, and compares —in CONSTANT time via OpenSSL.secure_compare — against every `v1,<sig>` in the header. Any match → Events::DESERIALIZERS`[type]` hydrates the snake-native body into the typed Events member; an unknown `type` → UnknownWebhookEventError; no match →WebhookSignatureError. It NEVER returns an unverified event (security by behavior — the name `extract` returns the event, not a boolean).

Both ‘secret` and the signature header accept multiple values, so secret rotation works from either side. Verification needs no OAuth credentials, hence a module-function (not a client method). Mirrors `sdk-js` `src/runtime/webhooks.ts` (@ `19c9bca`), in Ruby (`openssl` stdlib; base64 via `String#unpack1(“m”)` / `Array#pack(“m0”)` — no extra dependency).

── runtime ↔ generated boundary (controlled inverse import — openapi-SoT, §4) ──The general rule is “runtime/ never imports generated/”. ‘webhooks.rb` is one of two declared exceptions (the other is `http.rb → ERROR_REGISTRY`). The webhook event catalog’s SoT is ‘openapi.yaml` (`webhooks:`), so the typed members AND the Events::DESERIALIZERS table live in `generated/events/`. This module references the table at call time; the barrel (`lib/dinie.rb`) loads `generated/events` before any call. The reference is the forcing-function: an event not in openapi is not in `generated/events`, not in the table, so `extract` raises UnknownWebhookEventError — forcing the contract conversation.

── Header normalization (deferred decision §21.5 — RESOLVED: case-insensitive + symbol/string) ──Standard Webhooks headers are lowercase dashed (‘webhook-id`). Inbound frameworks vary in capitalization, so the lookup is case-insensitive and accepts both String and Symbol keys. The partner passes a Hash keyed by the `webhook-*` names; framework env normalization (Rack’s ‘HTTP_WEBHOOK_ID`) is the partner’s responsibility (a framework adapter is out of scope, §1):

# Rails:   Dinie::Webhooks.extract(headers: request.headers.to_h, body: request.raw_post, secret:)
# Sinatra: Dinie::Webhooks.extract(headers: { "webhook-id" => request.env["HTTP_WEBHOOK_ID"],
#            "webhook-timestamp" => request.env["HTTP_WEBHOOK_TIMESTAMP"],
#            "webhook-signature" => request.env["HTTP_WEBHOOK_SIGNATURE"] }, body: request.body.read, secret:)
# Rack:    headers = env.select { |k, _| k.start_with?("HTTP_") }
#            .transform_keys { |k| k.delete_prefix("HTTP_").downcase.tr("_", "-") }

Constant Summary collapse

DEFAULT_TOLERANCE_SECONDS =

Default replay tolerance, in seconds (5 minutes), applied in both directions.

300
WHSEC_PREFIX =

Secrets carrying this prefix are base64-encoded; the remainder is decoded before HMAC.

"whsec_"
SIGNATURE_VERSION =

Only ‘v1` signature tokens are honored.

"v1"

Class Method Summary collapse

Class Method Details

.decode_secret(secret) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

‘whsec_`-prefixed secrets are base64; everything else is treated as raw bytes.



175
176
177
178
179
# File 'lib/dinie/runtime/webhooks.rb', line 175

def decode_secret(secret)
  return secret.delete_prefix(WHSEC_PREFIX).unpack1("m") if secret.start_with?(WHSEC_PREFIX)

  secret.b
end

.deserialize_verified_event(body) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Deserialize a VERIFIED body per type (reached only after a signature matched, so the bytes are authentic). Dispatch is table-driven through Events::DESERIALIZERS; a ‘type` absent from the catalog raises UnknownWebhookEventError (forces the contract conversation).



185
186
187
188
189
190
191
192
# File 'lib/dinie/runtime/webhooks.rb', line 185

def deserialize_verified_event(body)
  raw = JSON.parse(body, symbolize_names: true)
  event_type = raw[:type]
  deserializer = event_type.is_a?(String) ? Dinie::Events::DESERIALIZERS[event_type] : nil
  raise Dinie::UnknownWebhookEventError, event_type.to_s if deserializer.nil?

  deserializer.call(raw)
end

.extract(headers:, body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS) ⇒ Dinie::Events::WebhookEventBase

Verify a Standard Webhooks v1 payload and return the deserialized, typed event — a Events member straight from openapi (‘generated/events/`), hydrated per type so the snake-native surface is honest. It NEVER returns an unverified event.

Parameters:

  • headers (Hash)

    inbound HTTP headers (case-insensitive lookup of the ‘webhook-*` trio)

  • body (String)

    the RAW request body, exactly as received, BEFORE ‘JSON.parse`

  • secret (String, Array<String>)

    signing secret(s); ‘whsec_`-prefixed → base64. An Array tries each secret (rotation)

  • tolerance_seconds (Integer) (defaults to: DEFAULT_TOLERANCE_SECONDS)

    replay window in seconds, applied in both directions

Returns:

Raises:



77
78
79
80
81
82
83
84
85
# File 'lib/dinie/runtime/webhooks.rb', line 77

def extract(headers:, body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS)
  webhook_id = required_header(headers, "webhook-id", Dinie::WebhookSignatureError)
  timestamp = required_header(headers, "webhook-timestamp", Dinie::WebhookTimestampError)
  signature = required_header(headers, "webhook-signature", Dinie::WebhookSignatureError)

  verify_timestamp!(timestamp, tolerance_seconds)
  verify_signature!(signed_payload(webhook_id, timestamp, body), secret, signature)
  deserialize_verified_event(body)
end

.fetch_header(headers, name) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



112
113
114
115
116
117
118
119
# File 'lib/dinie/runtime/webhooks.rb', line 112

def fetch_header(headers, name)
  return headers[name] if headers.key?(name)
  return headers[name.to_sym] if headers.key?(name.to_sym)

  target = name.downcase
  headers.each { |key, value| return value if key.to_s.downcase == target }
  nil
end

.lookup_header(headers, name) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Case-insensitive, symbol/string single-value header lookup (§21.5). A list value (rare) yields its first element.



106
107
108
109
# File 'lib/dinie/runtime/webhooks.rb', line 106

def lookup_header(headers, name)
  value = fetch_header(headers, name)
  value.is_a?(Array) ? value.first : value
end

.normalize_secrets(secret) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



147
148
149
# File 'lib/dinie/runtime/webhooks.rb', line 147

def normalize_secrets(secret)
  Array(secret).select { |candidate| candidate.is_a?(String) && !candidate.empty? }
end

.parse_signature_header(header) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Split the ‘webhook-signature` header into decoded `v1` signatures. Space-separated `v1,<base64>` tokens; non-`v1` and malformed tokens are dropped.



154
155
156
157
158
159
160
161
# File 'lib/dinie/runtime/webhooks.rb', line 154

def parse_signature_header(header)
  header.split.filter_map do |token|
    version, separator, value = token.partition(",")
    next if separator.empty? || value.empty? || version != SIGNATURE_VERSION

    value.unpack1("m")
  end
end

.required_header(headers, name, error_class) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Fetch a required header or raise ‘error_class`. Missing and empty both fail.

Raises:

  • (error_class)


96
97
98
99
100
101
# File 'lib/dinie/runtime/webhooks.rb', line 96

def required_header(headers, name, error_class)
  value = lookup_header(headers, name)
  return value unless value.nil? || value == ""

  raise error_class, "Missing required webhook header: #{name}."
end

.signatures_match?(payload, secrets, provided_signatures) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

True when any (secret, signature) pair matches. OpenSSL.secure_compare is constant-time and safe on unequal-length inputs (it digests both first), so no length guard is needed.

Returns:

  • (Boolean)


166
167
168
169
170
171
# File 'lib/dinie/runtime/webhooks.rb', line 166

def signatures_match?(payload, secrets, provided_signatures)
  secrets.any? do |secret|
    expected = OpenSSL::HMAC.digest("SHA256", decode_secret(secret), payload)
    provided_signatures.any? { |candidate| OpenSSL.secure_compare(candidate, expected) }
  end
end

.signed_payload(webhook_id, timestamp, body) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Build the byte-faithful signed payload ‘“id.timestamp.body”` (binary-concatenated so a non-UTF-8 body never raises an encoding error).



90
91
92
# File 'lib/dinie/runtime/webhooks.rb', line 90

def signed_payload(webhook_id, timestamp, body)
  "#{webhook_id}.#{timestamp}.".b + body.to_s.b
end

.verify_signature!(payload, secret, signature_header) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Raise unless any (secret, signature) pair matches (constant-time, multi-sig rotation).



137
138
139
140
141
142
143
144
# File 'lib/dinie/runtime/webhooks.rb', line 137

def verify_signature!(payload, secret, signature_header)
  secrets = normalize_secrets(secret)
  raise Dinie::WebhookSignatureError, "No webhook secret provided." if secrets.empty?
  return if signatures_match?(payload, secrets, parse_signature_header(signature_header))

  raise Dinie::WebhookSignatureError,
        "No matching webhook signature found; the payload may have been tampered with."
end

.verify_timestamp!(timestamp, tolerance_seconds) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Enforce the bidirectional replay window: reject a malformed, too-old, or too-far-future timestamp.



124
125
126
127
128
129
130
131
132
133
# File 'lib/dinie/runtime/webhooks.rb', line 124

def verify_timestamp!(timestamp, tolerance_seconds)
  seconds = Integer(timestamp.to_s.strip, 10, exception: false)
  raise Dinie::WebhookTimestampError, "Invalid webhook timestamp: #{timestamp.inspect}." if seconds.nil?

  skew = Time.now.to_i - seconds
  raise Dinie::WebhookTimestampError, "Webhook timestamp is too old." if skew > tolerance_seconds
  return unless skew < -tolerance_seconds

  raise Dinie::WebhookTimestampError, "Webhook timestamp is too far in the future."
end