Module: TrueTrial::Webhook

Defined in:
lib/truetrial/webhook.rb

Overview

Utilities for verifying and parsing incoming TrueTrial webhooks.

Webhook payloads are signed with HMAC SHA-256. The signature is delivered in the X-TrueTrial-Signature header and is computed from the raw JSON body using the webhook secret.

Class Method Summary collapse

Class Method Details

.compute_signature(payload, secret) ⇒ String

Computes the HMAC SHA-256 hex digest for a payload.

Parameters:

  • payload (String)

    the raw payload

  • secret (String)

    the signing secret

Returns:

  • (String)

    hex-encoded HMAC signature



60
61
62
# File 'lib/truetrial/webhook.rb', line 60

def compute_signature(payload, secret)
  OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
end

.construct_event(payload, signature, secret, tolerance: nil, timestamp: nil) ⇒ Hash

Verifies and parses a webhook payload into a hash.

Parameters:

  • payload (String)

    the raw JSON request body

  • signature (String)

    the value of the X-TrueTrial-Signature header

  • secret (String)

    the webhook signing secret

  • tolerance (Integer, nil) (defaults to: nil)

    maximum age of the event in seconds (optional)

  • timestamp (String, nil) (defaults to: nil)

    the value of the X-TrueTrial-Timestamp header (required when tolerance is set)

Returns:

  • (Hash)

    the parsed event data

Raises:



47
48
49
50
51
52
53
# File 'lib/truetrial/webhook.rb', line 47

def construct_event(payload, signature, secret, tolerance: nil, timestamp: nil)
  unless verify?(payload, signature, secret, tolerance: tolerance, timestamp: timestamp)
    raise Error.new("Invalid webhook signature")
  end

  JSON.parse(payload)
end

.secure_compare(a, b) ⇒ Boolean

Performs a constant-time string comparison to prevent timing attacks.

Parameters:

  • a (String)
  • b (String)

Returns:

  • (Boolean)


69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/truetrial/webhook.rb', line 69

def secure_compare(a, b)
  return false unless a.bytesize == b.bytesize

  OpenSSL.fixed_length_secure_compare(a, b)
rescue NoMethodError
  # Fallback for older OpenSSL versions
  l = a.unpack("C*")
  r = b.unpack("C*")
  result = 0
  l.zip(r) { |x, y| result |= x ^ y }
  result.zero?
end

.verify?(payload, signature, secret, tolerance: nil, timestamp: nil) ⇒ Boolean

Verifies that a webhook payload matches its signature.

Parameters:

  • payload (String)

    the raw JSON request body

  • signature (String)

    the value of the X-TrueTrial-Signature header

  • secret (String)

    the webhook signing secret

  • tolerance (Integer, nil) (defaults to: nil)

    maximum age of the event in seconds (optional)

  • timestamp (String, nil) (defaults to: nil)

    the value of the X-TrueTrial-Timestamp header (required when tolerance is set)

Returns:

  • (Boolean)

    true if the signature is valid



24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/truetrial/webhook.rb', line 24

def verify?(payload, signature, secret, tolerance: nil, timestamp: nil)
  expected = compute_signature(payload, secret)
  valid = secure_compare(expected, signature)

  if valid && tolerance && timestamp
    event_time = Time.parse(timestamp)
    valid = (Time.now - event_time).abs <= tolerance
  end

  valid
rescue ArgumentError, TypeError
  false
end