Module: Sendara::Webhooks

Defined in:
lib/sendara/webhooks.rb

Constant Summary collapse

SIGNATURE_HEADER =
"Sendara-Signature"
TIMESTAMP_HEADER =
"Sendara-Timestamp"
EVENT_ID_HEADER =
"Sendara-Event-Id"
EVENT_TYPE_HEADER =
"Sendara-Event-Type"

Class Method Summary collapse

Class Method Details

.header_value(headers, name) ⇒ Object



54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/sendara/webhooks.rb', line 54

def header_value(headers, name)
  return nil if headers.nil?

  if headers.respond_to?(:[]) && !headers.is_a?(Array)
    direct = headers[name] || headers[name.downcase] || headers[name.upcase]
    return scalar_header(direct) unless direct.nil?
  end

  lower = name.downcase
  headers.each do |key, value|
    return scalar_header(value) if key.to_s.downcase == lower
  end
  nil
end

.scalar_header(value) ⇒ Object



69
70
71
72
# File 'lib/sendara/webhooks.rb', line 69

def scalar_header(value)
  value = value.first if value.is_a?(Array)
  value.nil? ? nil : value.to_s
end

.secure_compare(expected, provided) ⇒ Object



46
47
48
49
50
51
52
# File 'lib/sendara/webhooks.rb', line 46

def secure_compare(expected, provided)
  expected_bytes = expected.to_s.b
  provided_bytes = provided.to_s.b
  return false unless expected_bytes.bytesize == provided_bytes.bytesize

  OpenSSL.fixed_length_secure_compare(expected_bytes, provided_bytes)
end

.sign(secret, timestamp, raw_body) ⇒ Object



42
43
44
# File 'lib/sendara/webhooks.rb', line 42

def sign(secret, timestamp, raw_body)
  OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
end

.verify(payload, headers, secret, tolerance: 300) ⇒ Object



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/sendara/webhooks.rb', line 15

def verify(payload, headers, secret, tolerance: 300)
  raise WebhookVerificationError, "A signing secret is required" if secret.nil? || secret.empty?

  raw_body = payload.to_s
  signature = header_value(headers, SIGNATURE_HEADER)
  timestamp = header_value(headers, TIMESTAMP_HEADER)

  raise WebhookVerificationError, "Missing #{SIGNATURE_HEADER} header" if signature.nil? || signature.empty?
  raise WebhookVerificationError, "Missing #{TIMESTAMP_HEADER} header" if timestamp.nil? || timestamp.empty?

  if tolerance.positive?
    raise WebhookVerificationError, "Invalid timestamp header" unless timestamp.match?(/\A-?\d+\z/)

    skew = (Time.now.to_i - timestamp.to_i).abs
    raise WebhookVerificationError, "Timestamp outside tolerance window" if skew > tolerance
  end

  expected = sign(secret, timestamp, raw_body)
  raise WebhookVerificationError, "Signature mismatch" unless secure_compare(expected, signature)

  begin
    JSON.parse(raw_body)
  rescue JSON::ParserError
    raise WebhookVerificationError, "Body is not valid JSON"
  end
end