Module: EasyDocForms::Webhook

Defined in:
lib/easydocforms/webhook.rb

Overview

Verifies webhook deliveries against a subscription's whsec_* secret (returned once by Client#create_webhook).

Every delivery is signed:

X-EDF-Signature: t=<unix>,v1=<hex hmac-sha256(secret, "<t>.<raw body>")>

Verify over the RAW request body, before any JSON parsing:

event = EasyDocForms::Webhook.construct_event(
payload: request.body.read,
header:  request.headers["X-EDF-Signature"],
secret:  ENV["EASYDOCFORMS_WEBHOOK_SECRET"]
)
case event[:event]
when "submission.created" then ... # event[:data][:submission_id]
end

Constant Summary collapse

HEADER =
"X-EDF-Signature"
DEFAULT_TOLERANCE =

seconds

300

Class Method Summary collapse

Class Method Details

.construct_event(payload:, header:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now) ⇒ Object

Verifies a delivery, then parses and returns its envelope as a Hash with symbol keys: { event:, timestamp:, org_id:, data: }.



46
47
48
49
# File 'lib/easydocforms/webhook.rb', line 46

def self.construct_event(payload:, header:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
  verify!(payload: payload, header: header, secret: secret, tolerance: tolerance, now: now)
  JSON.parse(payload, symbolize_names: true)
end

.sign(payload:, secret:, timestamp: Time.now.to_i) ⇒ Object

Computes an X-EDF-Signature header value — for building authentic fixtures in your own tests.



53
54
55
# File 'lib/easydocforms/webhook.rb', line 53

def self.sign(payload:, secret:, timestamp: Time.now.to_i)
  "t=#{timestamp},v1=#{OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")}"
end

.verify!(payload:, header:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now) ⇒ Object

Verifies a delivery's signature and timestamp. Returns true, or raises EasyDocForms::SignatureVerificationError. payload must be the raw request body string; tolerance bounds |now - t| (replay window).



31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/easydocforms/webhook.rb', line 31

def self.verify!(payload:, header:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
  timestamp, signatures = parse_header(header)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
  unless signatures.any? { |signature| secure_compare(signature, expected) }
    raise SignatureVerificationError, "no v1 signature matches the payload"
  end
  if (now.to_i - timestamp).abs > tolerance
    raise SignatureVerificationError, "timestamp outside the #{tolerance}s tolerance"
  end

  true
end