Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,32 @@ puts(file.id)

Note that you can also pass a raw `IO` descriptor, but this disables retries, as the library can't be sure if the descriptor is a file or pipe (which cannot be rewound).

### Webhook verification

Verifying webhook signatures is _optional but encouraged_.

For more information about webhooks, see [the API docs](https://increase.com/documentation/webhooks#events-and-webhooks).

### Parsing webhook payloads

For most use cases, you will likely want to verify the webhook and parse the payload at the same time. To achieve this, we provide the method `Increase::Webhooks.unwrap`, which parses a webhook request and verifies that it was sent by Increase. This method will raise an error if the signature is invalid.

Note that the `body` parameter must be the raw JSON string sent from the server (do not parse it first). The `.unwrap()` method will parse this JSON for you into an event object after verifying the webhook was sent from Increase.

```ruby
require 'sinatra'

post '/webhook' do
request.body.rewind

event = Increase::Webhooks.unwrap(
request.body.read,
request.get_header('Increase-Webhook-Signature'),
"your webhook secret"
)
end
```

### Handling errors

When the library is unable to connect to the API, or if the API returns a non-success status code (i.e., 4xx or 5xx response), a subclass of `Increase::Errors::APIError` will be thrown:
Expand Down
1 change: 1 addition & 0 deletions lib/increase.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
require_relative "increase/request_options"
require_relative "increase/file_part"
require_relative "increase/errors"
require_relative "increase/webhooks"
require_relative "increase/internal/transport/base_client"
require_relative "increase/internal/transport/pooled_net_requester"
require_relative "increase/client"
Expand Down
44 changes: 44 additions & 0 deletions lib/increase/webhooks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

module Increase
module Webhooks
class << self
def unwrap(payload, signature, secret = nil)
if secret
verify_signature(payload, signature, secret)
end

JSON.parse(payload)
end

def verify_signature(payload, signature, secret = nil)
begin
parsed_signature = parse_kv_pairs(signature)
rescue StandardError
raise "Unable to parse webhook signature."
end

timestamp = parsed_signature["t"]
signature = parsed_signature["v1"]

expected_signature = OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new("sha256"),
secret,
"#{timestamp}.#{payload}"
)

unless OpenSSL.secure_compare(expected_signature, signature)
raise "None of the given webhook signatures match the expected signature."
end

nil
end

private

def parse_kv_pairs(text, item_sep: ",", value_sep: "=")
Hash[*text.split(item_sep).map { |t| t.split(value_sep) }.flatten]
end
end
end
end
52 changes: 52 additions & 0 deletions test/increase/webhooks_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

require_relative "test_helper"

class Increase::Test::WebhooksTest < Minitest::Test
def setup
@timestamp = Time.now.utc.iso8601
@secret = "whsec_test_secret"
@payload = "{\"type\":\"event\",\"id\":\"event_123abc\",\"created_at\":\"2020-01-31T23:59:59Z\"}"
@signed_payload = OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new("sha256"),
@secret,
"#{@timestamp}.#{@payload}"
)
@signature = "t=#{@timestamp},v1=#{@signed_payload}"
end

def test_unwrap
unwrapped = Increase::Webhooks.unwrap(@payload, @signature, @secret)
assert_equal(unwrapped, JSON.parse(@payload))

assert_raises(StandardError, "None of the given webhook signatures match the expected signature.") do
Increase::Webhooks.unwrap(@payload, @signature, "wrong_secret")
end
end

def test_verify_signature_valid
assert_nil(Increase::Webhooks.verify_signature(@payload, @signature, @secret))
end

def test_verify_signature_invalid_secret
assert_raises(StandardError, "None of the given webhook signatures match the expected signature.") do
Increase::Webhooks.verify_signature(@payload, @signature, "wrong_secret")
end
end

def test_verify_signature_invalid_payload
wrong_payload = "{\"type\":\"event\",\"id\":\"event_different\"}"

assert_raises(StandardError, "None of the given webhook signatures match the expected signature.") do
Increase::Webhooks.verify_signature(wrong_payload, @signature, @secret)
end
end

def test_verify_signature_invalid_signature_format
invalid_signature = "???"

assert_raises(StandardError, "Unable to parse webhook signature.") do
Increase::Webhooks.verify_signature(@payload, invalid_signature, @secret)
end
end
end