Module: Finlight::WebhookService

Defined in:
lib/finlight/webhook_service.rb

Overview

Securely receives and verifies webhook events from finlight: HMAC-SHA256 signature verification with replay attack protection.

Constant Summary collapse

SIGNATURE_PREFIX =
"sha256="
REPLAY_TOLERANCE_SECONDS =
5 * 60

Class Method Summary collapse

Class Method Details

.compute_signature(payload, secret) ⇒ Object



47
48
49
# File 'lib/finlight/webhook_service.rb', line 47

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

.construct_event(raw_body, signature, endpoint_secret, timestamp = nil) ⇒ Article

Constructs and verifies a webhook event from raw request data.

Examples:

Rack/Rails endpoint

article = Finlight::WebhookService.construct_event(
  request.raw_post,
  request.headers["X-Webhook-Signature"],
  ENV["WEBHOOK_SECRET"],
  request.headers["X-Webhook-Timestamp"]
)

Parameters:

  • raw_body (String)

    the raw, unparsed request body

  • signature (String)

    the X-Webhook-Signature header (with or without the "sha256=" prefix)

  • endpoint_secret (String)

    your webhook secret from the dashboard

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

    the X-Webhook-Timestamp header; when given it is included in the signed message and checked against a 5-minute replay tolerance

Returns:

  • (Article)

    the verified and parsed article

Raises:



34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/finlight/webhook_service.rb', line 34

def construct_event(raw_body, signature, endpoint_secret, timestamp = nil)
  timestamp = nil if timestamp.to_s.empty?
  normalized = signature.to_s.delete_prefix(SIGNATURE_PREFIX)

  message = timestamp ? "#{timestamp}.#{raw_body}" : raw_body
  expected = compute_signature(message, endpoint_secret)
  raise WebhookVerificationError, "Invalid webhook signature" unless secure_compare(normalized, expected)

  verify_timestamp(timestamp) if timestamp

  parse_payload(raw_body)
end

.parse_payload(raw_body) ⇒ Object



63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/finlight/webhook_service.rb', line 63

def parse_payload(raw_body)
  data = begin
    JSON.parse(raw_body)
  rescue JSON::ParserError
    raise WebhookVerificationError, "Invalid JSON payload"
  end

  begin
    Article.from_h(data)
  rescue StandardError => e
    raise WebhookVerificationError, "Invalid article data: #{e.message}"
  end
end

.secure_compare(left, right) ⇒ Object



77
78
79
80
81
# File 'lib/finlight/webhook_service.rb', line 77

def secure_compare(left, right)
  return false unless left.bytesize == right.bytesize

  OpenSSL.fixed_length_secure_compare(left, right)
end

.verify_timestamp(timestamp) ⇒ Object



51
52
53
54
55
56
57
58
59
60
61
# File 'lib/finlight/webhook_service.rb', line 51

def verify_timestamp(timestamp)
  begin
    webhook_time = FlexTime.parse(timestamp)
  rescue Error
    raise WebhookVerificationError, "Invalid timestamp format"
  end

  return unless (Time.now.utc - webhook_time).abs > REPLAY_TOLERANCE_SECONDS

  raise WebhookVerificationError, "Webhook timestamp outside allowed tolerance"
end