Module: Muxi::Webhook

Defined in:
lib/muxi/webhook.rb

Defined Under Namespace

Classes: Clarification, ContentItem, ErrorDetails, VerificationError, WebhookEvent

Class Method Summary collapse

Class Method Details

.parse(payload) ⇒ Object



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/muxi/webhook.rb', line 108

def parse(payload)
  data = case payload
         when Hash
           payload
         when String
           begin
             JSON.parse(payload)
           rescue JSON::ParserError => e
             raise VerificationError, "Invalid JSON payload: #{e.message}"
           end
         else
           raise VerificationError, "Unsupported payload type: #{payload.class}"
         end

  WebhookEvent.from_hash(data)
end

.secure_compare(a, b) ⇒ Object



125
126
127
128
129
130
131
132
133
# File 'lib/muxi/webhook.rb', line 125

def secure_compare(a, b)
  return false if a.nil? || b.nil?
  return false if a.bytesize != b.bytesize

  l = a.unpack("C*")
  r = 0
  b.each_byte { |v| r |= v ^ l.shift }
  r == 0
end

.verify_signature(payload, signature_header, secret, tolerance_seconds: 300) ⇒ Object

Raises:



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/muxi/webhook.rb', line 76

def verify_signature(payload, signature_header, secret, tolerance_seconds: 300)
  return false if signature_header.nil? || signature_header.empty?
  raise VerificationError, "Webhook secret is required" if secret.nil? || secret.empty?

  # Parse signature header: "t=1234567890,v1=abc123..."
  begin
    parts = signature_header.split(",").map { |p| p.split("=", 2) }.to_h
    timestamp_str = parts["t"]
    signature = parts["v1"]

    return false if timestamp_str.nil? || signature.nil?

    timestamp = timestamp_str.to_i
  rescue StandardError
    return false
  end

  # Check timestamp tolerance
  current_time = Time.now.to_i
  return false if (current_time - timestamp).abs > tolerance_seconds

  # Normalize payload
  payload_bytes = payload.is_a?(String) ? payload : payload.to_s

  # Compute expected signature
  message = "#{timestamp}.#{payload_bytes}"
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, message)

  # Constant-time comparison
  secure_compare(expected, signature)
end