hooksmith 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +295 -64
- data/lib/hooksmith/config/provider.rb +25 -4
- data/lib/hooksmith/configuration.rb +46 -7
- data/lib/hooksmith/dispatcher.rb +71 -34
- data/lib/hooksmith/errors.rb +240 -0
- data/lib/hooksmith/idempotency.rb +107 -0
- data/lib/hooksmith/instrumentation.rb +112 -0
- data/lib/hooksmith/jobs/dispatcher_job.rb +63 -0
- data/lib/hooksmith/rails/webhooks_controller.rb +152 -0
- data/lib/hooksmith/request.rb +110 -0
- data/lib/hooksmith/verifiers/base.rb +79 -0
- data/lib/hooksmith/verifiers/bearer_token.rb +79 -0
- data/lib/hooksmith/verifiers/hmac.rb +184 -0
- data/lib/hooksmith/version.rb +1 -1
- data/lib/hooksmith.rb +51 -1
- metadata +32 -3
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hooksmith
|
|
4
|
+
module Rails
|
|
5
|
+
# A concern for Rails controllers that handle webhooks.
|
|
6
|
+
#
|
|
7
|
+
# This concern provides standardized webhook handling with:
|
|
8
|
+
# - Automatic request verification (if configured)
|
|
9
|
+
# - Consistent response codes (200 for success, 400 for bad request, 500 for errors)
|
|
10
|
+
# - Error logging and instrumentation
|
|
11
|
+
# - Skip CSRF protection for webhook endpoints
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# class WebhooksController < ApplicationController
|
|
15
|
+
# include Hooksmith::Rails::WebhooksController
|
|
16
|
+
#
|
|
17
|
+
# def stripe
|
|
18
|
+
# handle_webhook(provider: 'stripe', event: params[:type], payload: params.to_unsafe_h)
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @example With custom error handling
|
|
23
|
+
# class WebhooksController < ApplicationController
|
|
24
|
+
# include Hooksmith::Rails::WebhooksController
|
|
25
|
+
#
|
|
26
|
+
# def stripe
|
|
27
|
+
# handle_webhook(provider: 'stripe', event: params[:type], payload: params.to_unsafe_h) do |result|
|
|
28
|
+
# # Custom success handling
|
|
29
|
+
# render json: { processed: true, result: result }
|
|
30
|
+
# end
|
|
31
|
+
# rescue Hooksmith::VerificationError => e
|
|
32
|
+
# render json: { error: 'Invalid signature' }, status: :unauthorized
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# @example Async processing with ActiveJob
|
|
37
|
+
# class WebhooksController < ApplicationController
|
|
38
|
+
# include Hooksmith::Rails::WebhooksController
|
|
39
|
+
#
|
|
40
|
+
# def stripe
|
|
41
|
+
# handle_webhook_async(provider: 'stripe', event: params[:type], payload: params.to_unsafe_h)
|
|
42
|
+
# end
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
module WebhooksController
|
|
46
|
+
extend ActiveSupport::Concern
|
|
47
|
+
|
|
48
|
+
included do
|
|
49
|
+
skip_before_action :verify_authenticity_token, raise: false
|
|
50
|
+
before_action :verify_webhook_signature, if: :hooksmith_verification_enabled?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Handles a webhook synchronously.
|
|
54
|
+
#
|
|
55
|
+
# @param provider [String, Symbol] the webhook provider
|
|
56
|
+
# @param event [String, Symbol] the event type
|
|
57
|
+
# @param payload [Hash] the webhook payload
|
|
58
|
+
# @yield [result] optional block for custom success handling
|
|
59
|
+
# @yieldparam result [Object] the result from the processor
|
|
60
|
+
# @return [void]
|
|
61
|
+
def handle_webhook(provider:, event:, payload:)
|
|
62
|
+
result = Hooksmith::Dispatcher.new(provider:, event:, payload:).run!
|
|
63
|
+
|
|
64
|
+
if block_given?
|
|
65
|
+
yield(result)
|
|
66
|
+
else
|
|
67
|
+
head :ok
|
|
68
|
+
end
|
|
69
|
+
rescue Hooksmith::MultipleProcessorsError => e
|
|
70
|
+
Hooksmith.logger.error("Webhook error: #{e.message}")
|
|
71
|
+
head :internal_server_error
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
Hooksmith.logger.error("Webhook processing failed: #{e.message}")
|
|
74
|
+
head :internal_server_error
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Handles a webhook asynchronously using ActiveJob.
|
|
78
|
+
#
|
|
79
|
+
# Requires Hooksmith::Jobs::DispatcherJob to be available.
|
|
80
|
+
#
|
|
81
|
+
# @param provider [String, Symbol] the webhook provider
|
|
82
|
+
# @param event [String, Symbol] the event type
|
|
83
|
+
# @param payload [Hash] the webhook payload
|
|
84
|
+
# @param queue [Symbol, String] the queue to use (default: :default)
|
|
85
|
+
# @return [void]
|
|
86
|
+
def handle_webhook_async(provider:, event:, payload:, queue: :default)
|
|
87
|
+
unless defined?(Hooksmith::Jobs::DispatcherJob)
|
|
88
|
+
raise 'Hooksmith::Jobs::DispatcherJob is not available. Ensure ActiveJob is loaded.'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
Hooksmith::Jobs::DispatcherJob.set(queue:).perform_later(
|
|
92
|
+
provider: provider.to_s,
|
|
93
|
+
event: event.to_s,
|
|
94
|
+
payload: payload.as_json
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
head :ok
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Verifies the webhook signature using the provider's configured verifier.
|
|
103
|
+
#
|
|
104
|
+
# Override this method to customize verification behavior.
|
|
105
|
+
#
|
|
106
|
+
# @return [void]
|
|
107
|
+
# @raise [Hooksmith::VerificationError] if verification fails
|
|
108
|
+
def verify_webhook_signature
|
|
109
|
+
provider = hooksmith_provider_name
|
|
110
|
+
return unless provider
|
|
111
|
+
|
|
112
|
+
verifier = Hooksmith.configuration.verifier_for(provider)
|
|
113
|
+
return unless verifier&.enabled?
|
|
114
|
+
|
|
115
|
+
hooksmith_request = Hooksmith::Request.new(
|
|
116
|
+
headers: request.headers.to_h,
|
|
117
|
+
body: request.raw_post
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
verifier.verify!(hooksmith_request)
|
|
121
|
+
rescue Hooksmith::VerificationError => e
|
|
122
|
+
Hooksmith.logger.warn("Webhook verification failed for #{provider}: #{e.message}")
|
|
123
|
+
head :unauthorized
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Returns the provider name for the current action.
|
|
127
|
+
#
|
|
128
|
+
# Override this method to customize provider detection.
|
|
129
|
+
# By default, uses the action name.
|
|
130
|
+
#
|
|
131
|
+
# @return [String, nil] the provider name
|
|
132
|
+
def hooksmith_provider_name
|
|
133
|
+
action_name
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Checks if webhook verification is enabled for the current provider.
|
|
137
|
+
#
|
|
138
|
+
# Override this method to customize when verification runs.
|
|
139
|
+
#
|
|
140
|
+
# @return [Boolean] true if verification should run
|
|
141
|
+
def hooksmith_verification_enabled?
|
|
142
|
+
return false unless Hooksmith.configuration.respond_to?(:verifier_for)
|
|
143
|
+
|
|
144
|
+
provider = hooksmith_provider_name
|
|
145
|
+
return false unless provider
|
|
146
|
+
|
|
147
|
+
verifier = Hooksmith.configuration.verifier_for(provider)
|
|
148
|
+
verifier&.enabled? || false
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hooksmith
|
|
4
|
+
# Wrapper for incoming webhook request data.
|
|
5
|
+
#
|
|
6
|
+
# This class provides a consistent interface for accessing request data
|
|
7
|
+
# regardless of the underlying web framework (Rails, Rack, etc.).
|
|
8
|
+
#
|
|
9
|
+
# @example Creating a request from Rails controller
|
|
10
|
+
# request = Hooksmith::Request.new(
|
|
11
|
+
# headers: request.headers.to_h,
|
|
12
|
+
# body: request.raw_post,
|
|
13
|
+
# method: request.request_method,
|
|
14
|
+
# path: request.path
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# @example Creating a request from Rack env
|
|
18
|
+
# request = Hooksmith::Request.from_rack_env(env)
|
|
19
|
+
#
|
|
20
|
+
class Request
|
|
21
|
+
# @return [Hash] the request headers
|
|
22
|
+
attr_reader :headers
|
|
23
|
+
# @return [String] the raw request body
|
|
24
|
+
attr_reader :body
|
|
25
|
+
# @return [String] the HTTP method (GET, POST, etc.)
|
|
26
|
+
attr_reader :method
|
|
27
|
+
# @return [String] the request path
|
|
28
|
+
attr_reader :path
|
|
29
|
+
# @return [Hash] the parsed payload (optional, for convenience)
|
|
30
|
+
attr_reader :payload
|
|
31
|
+
|
|
32
|
+
# Initializes a new Request.
|
|
33
|
+
#
|
|
34
|
+
# @param headers [Hash] the request headers
|
|
35
|
+
# @param body [String] the raw request body
|
|
36
|
+
# @param method [String] the HTTP method
|
|
37
|
+
# @param path [String] the request path
|
|
38
|
+
# @param payload [Hash] the parsed payload (optional)
|
|
39
|
+
def initialize(headers:, body:, method: 'POST', path: '/', payload: nil)
|
|
40
|
+
@headers = normalize_headers(headers)
|
|
41
|
+
@body = body.to_s
|
|
42
|
+
@method = method.to_s.upcase
|
|
43
|
+
@path = path.to_s
|
|
44
|
+
@payload = payload
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Creates a Request from a Rack environment hash.
|
|
48
|
+
#
|
|
49
|
+
# @param env [Hash] the Rack environment
|
|
50
|
+
# @return [Request] a new Request instance
|
|
51
|
+
def self.from_rack_env(env)
|
|
52
|
+
headers = extract_headers_from_rack(env)
|
|
53
|
+
body = env['rack.input']&.read || ''
|
|
54
|
+
env['rack.input']&.rewind
|
|
55
|
+
|
|
56
|
+
new(
|
|
57
|
+
headers:,
|
|
58
|
+
body:,
|
|
59
|
+
method: env['REQUEST_METHOD'],
|
|
60
|
+
path: env['PATH_INFO']
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Gets a header value by name (case-insensitive).
|
|
65
|
+
#
|
|
66
|
+
# @param name [String] the header name
|
|
67
|
+
# @return [String, nil] the header value or nil if not found
|
|
68
|
+
def header(name)
|
|
69
|
+
normalized_name = normalize_header_name(name)
|
|
70
|
+
@headers[normalized_name]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Alias for {#header}.
|
|
74
|
+
#
|
|
75
|
+
# @param name [String] the header name
|
|
76
|
+
# @return [String, nil] the header value or nil if not found
|
|
77
|
+
def [](name)
|
|
78
|
+
header(name)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Extracts headers from a Rack environment hash.
|
|
82
|
+
#
|
|
83
|
+
# @param env [Hash] the Rack environment
|
|
84
|
+
# @return [Hash] the extracted headers
|
|
85
|
+
def self.extract_headers_from_rack(env)
|
|
86
|
+
env.select { |k, _| k.start_with?('HTTP_') || %w[CONTENT_TYPE CONTENT_LENGTH].include?(k) }
|
|
87
|
+
end
|
|
88
|
+
private_class_method :extract_headers_from_rack
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Normalizes headers to use consistent key format.
|
|
93
|
+
#
|
|
94
|
+
# @param headers [Hash] the raw headers
|
|
95
|
+
# @return [Hash] normalized headers
|
|
96
|
+
def normalize_headers(headers)
|
|
97
|
+
return {} unless headers.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
headers.transform_keys { |k| normalize_header_name(k) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Normalizes a header name to a consistent format.
|
|
103
|
+
#
|
|
104
|
+
# @param name [String] the header name
|
|
105
|
+
# @return [String] the normalized header name
|
|
106
|
+
def normalize_header_name(name)
|
|
107
|
+
name.to_s.upcase.tr('-', '_').sub(/^HTTP_/, '')
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hooksmith
|
|
4
|
+
module Verifiers
|
|
5
|
+
# Base class for webhook request verifiers.
|
|
6
|
+
#
|
|
7
|
+
# Verifiers are responsible for authenticating incoming webhook requests
|
|
8
|
+
# before they are processed. Each provider can have its own verifier
|
|
9
|
+
# configured to handle provider-specific authentication schemes.
|
|
10
|
+
#
|
|
11
|
+
# @abstract Subclass and override {#verify!} to implement custom verification.
|
|
12
|
+
#
|
|
13
|
+
# @example Creating a custom verifier
|
|
14
|
+
# class MyCustomVerifier < Hooksmith::Verifiers::Base
|
|
15
|
+
# def verify!(request)
|
|
16
|
+
# token = request.headers['X-Custom-Token']
|
|
17
|
+
# raise Hooksmith::VerificationError, 'Invalid token' unless valid_token?(token)
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# private
|
|
21
|
+
#
|
|
22
|
+
# def valid_token?(token)
|
|
23
|
+
# token == @options[:expected_token]
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
class Base
|
|
28
|
+
# @return [Hash] options passed to the verifier
|
|
29
|
+
attr_reader :options
|
|
30
|
+
|
|
31
|
+
# Initializes the verifier with options.
|
|
32
|
+
#
|
|
33
|
+
# @param options [Hash] verifier-specific options
|
|
34
|
+
def initialize(**options)
|
|
35
|
+
@options = options
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Verifies the incoming webhook request.
|
|
39
|
+
#
|
|
40
|
+
# @param request [Hooksmith::Request] the incoming request to verify
|
|
41
|
+
# @raise [Hooksmith::VerificationError] if verification fails
|
|
42
|
+
# @return [void]
|
|
43
|
+
def verify!(request)
|
|
44
|
+
raise NotImplementedError, 'Subclasses must implement #verify!'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns whether the verifier is configured and should be used.
|
|
48
|
+
#
|
|
49
|
+
# @return [Boolean] true if the verifier should be applied
|
|
50
|
+
def enabled?
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
protected
|
|
55
|
+
|
|
56
|
+
# Performs a constant-time string comparison to prevent timing attacks.
|
|
57
|
+
#
|
|
58
|
+
# @param expected [String] expected string
|
|
59
|
+
# @param actual [String] actual string
|
|
60
|
+
# @return [Boolean] true if strings are equal
|
|
61
|
+
def secure_compare(expected, actual)
|
|
62
|
+
return false if expected.nil? || actual.nil?
|
|
63
|
+
return false if expected.bytesize != actual.bytesize
|
|
64
|
+
|
|
65
|
+
# Use OpenSSL's secure comparison if available (Ruby 2.5+)
|
|
66
|
+
if OpenSSL.respond_to?(:secure_compare)
|
|
67
|
+
OpenSSL.secure_compare(expected, actual)
|
|
68
|
+
else
|
|
69
|
+
# Fallback to manual constant-time comparison
|
|
70
|
+
left = expected.unpack('C*')
|
|
71
|
+
right = actual.unpack('C*')
|
|
72
|
+
result = 0
|
|
73
|
+
left.zip(right) { |x, y| result |= x ^ y }
|
|
74
|
+
result.zero?
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module Hooksmith
|
|
6
|
+
module Verifiers
|
|
7
|
+
# Bearer token webhook verifier.
|
|
8
|
+
#
|
|
9
|
+
# This verifier validates webhook requests using a simple bearer token
|
|
10
|
+
# in the Authorization header or a custom header.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic bearer token verification
|
|
13
|
+
# verifier = Hooksmith::Verifiers::BearerToken.new(
|
|
14
|
+
# token: ENV['WEBHOOK_TOKEN']
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# @example Custom header
|
|
18
|
+
# verifier = Hooksmith::Verifiers::BearerToken.new(
|
|
19
|
+
# token: ENV['WEBHOOK_TOKEN'],
|
|
20
|
+
# header: 'X-Webhook-Token'
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
class BearerToken < Base
|
|
24
|
+
# Default header for bearer tokens
|
|
25
|
+
DEFAULT_HEADER = 'Authorization'
|
|
26
|
+
|
|
27
|
+
# Initializes the bearer token verifier.
|
|
28
|
+
#
|
|
29
|
+
# @param token [String] the expected token value
|
|
30
|
+
# @param header [String] the header containing the token (default: Authorization)
|
|
31
|
+
# @param strip_bearer_prefix [Boolean] whether to strip 'Bearer ' prefix (default: true)
|
|
32
|
+
def initialize(token:, header: DEFAULT_HEADER, strip_bearer_prefix: true, **options)
|
|
33
|
+
super(**options)
|
|
34
|
+
@token = token
|
|
35
|
+
@header = header
|
|
36
|
+
@strip_bearer_prefix = strip_bearer_prefix
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Verifies the bearer token in the request.
|
|
40
|
+
#
|
|
41
|
+
# @param request [Hooksmith::Request] the incoming request
|
|
42
|
+
# @raise [Hooksmith::VerificationError] if verification fails
|
|
43
|
+
# @return [void]
|
|
44
|
+
def verify!(request)
|
|
45
|
+
provided_token = extract_token(request)
|
|
46
|
+
|
|
47
|
+
if provided_token.nil? || provided_token.empty?
|
|
48
|
+
raise VerificationError.new('Missing authentication token', reason: 'missing_token')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
return if secure_compare(@token, provided_token)
|
|
52
|
+
|
|
53
|
+
raise VerificationError.new('Invalid authentication token', reason: 'invalid_token')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns whether the verifier is properly configured.
|
|
57
|
+
#
|
|
58
|
+
# @return [Boolean] true if token is present
|
|
59
|
+
def enabled?
|
|
60
|
+
!@token.nil? && !@token.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Extracts the token from the request headers.
|
|
66
|
+
#
|
|
67
|
+
# @param request [Hooksmith::Request] the incoming request
|
|
68
|
+
# @return [String, nil] the extracted token
|
|
69
|
+
def extract_token(request)
|
|
70
|
+
raw_token = request.header(@header)
|
|
71
|
+
return nil if raw_token.nil?
|
|
72
|
+
|
|
73
|
+
token = raw_token.to_s.strip
|
|
74
|
+
token = token.sub(/\ABearer\s+/i, '') if @strip_bearer_prefix
|
|
75
|
+
token.empty? ? nil : token
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'base64'
|
|
5
|
+
|
|
6
|
+
module Hooksmith
|
|
7
|
+
module Verifiers
|
|
8
|
+
# HMAC-based webhook signature verifier.
|
|
9
|
+
#
|
|
10
|
+
# This verifier validates webhook requests using HMAC signatures,
|
|
11
|
+
# which is a common authentication method used by providers like
|
|
12
|
+
# Stripe, GitHub, Shopify, and many others.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic HMAC verification
|
|
15
|
+
# verifier = Hooksmith::Verifiers::Hmac.new(
|
|
16
|
+
# secret: ENV['WEBHOOK_SECRET'],
|
|
17
|
+
# header: 'X-Signature'
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# @example HMAC with timestamp validation (like Stripe)
|
|
21
|
+
# verifier = Hooksmith::Verifiers::Hmac.new(
|
|
22
|
+
# secret: ENV['STRIPE_WEBHOOK_SECRET'],
|
|
23
|
+
# header: 'Stripe-Signature',
|
|
24
|
+
# timestamp_options: { header: 'Stripe-Signature', tolerance: 300 }
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
# @example HMAC with custom signature format
|
|
28
|
+
# verifier = Hooksmith::Verifiers::Hmac.new(
|
|
29
|
+
# secret: ENV['WEBHOOK_SECRET'],
|
|
30
|
+
# header: 'X-Hub-Signature-256',
|
|
31
|
+
# algorithm: 'sha256',
|
|
32
|
+
# signature_prefix: 'sha256='
|
|
33
|
+
# )
|
|
34
|
+
#
|
|
35
|
+
class Hmac < Base
|
|
36
|
+
# Supported HMAC algorithms
|
|
37
|
+
ALGORITHMS = %w[sha1 sha256 sha384 sha512].freeze
|
|
38
|
+
|
|
39
|
+
# Default signature encoding
|
|
40
|
+
DEFAULT_ENCODING = :hex
|
|
41
|
+
|
|
42
|
+
# Initializes the HMAC verifier.
|
|
43
|
+
#
|
|
44
|
+
# @param secret [String] the shared secret key
|
|
45
|
+
# @param header [String] the header containing the signature
|
|
46
|
+
# @param algorithm [String] the HMAC algorithm (sha1, sha256, sha384, sha512)
|
|
47
|
+
# @param encoding [Symbol] the signature encoding (:hex or :base64)
|
|
48
|
+
# @param signature_prefix [String, nil] prefix to strip from signature (e.g., 'sha256=')
|
|
49
|
+
# @param timestamp_options [Hash] timestamp validation options
|
|
50
|
+
# @option timestamp_options [String] :header header containing the timestamp
|
|
51
|
+
# @option timestamp_options [Integer] :tolerance max age of request in seconds (default: 300)
|
|
52
|
+
# @option timestamp_options [Symbol] :format timestamp format (:unix or :iso8601)
|
|
53
|
+
def initialize(secret:, header:, **options)
|
|
54
|
+
# Extract known options and pass remaining to parent (avoids ActiveSupport dependency)
|
|
55
|
+
known_keys = %i[algorithm encoding signature_prefix timestamp_options]
|
|
56
|
+
# rubocop:disable Style/HashExcept -- intentionally avoiding ActiveSupport's Hash#except
|
|
57
|
+
parent_options = options.reject { |k, _| known_keys.include?(k) }
|
|
58
|
+
# rubocop:enable Style/HashExcept
|
|
59
|
+
super(**parent_options)
|
|
60
|
+
@secret = secret
|
|
61
|
+
@header = header
|
|
62
|
+
@algorithm = validate_algorithm(options.fetch(:algorithm, 'sha256'))
|
|
63
|
+
@encoding = options.fetch(:encoding, DEFAULT_ENCODING)
|
|
64
|
+
@signature_prefix = options[:signature_prefix]
|
|
65
|
+
configure_timestamp_options(options[:timestamp_options])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Verifies the HMAC signature of the request.
|
|
69
|
+
#
|
|
70
|
+
# @param request [Hooksmith::Request] the incoming request
|
|
71
|
+
# @raise [Hooksmith::VerificationError] if verification fails
|
|
72
|
+
# @return [void]
|
|
73
|
+
def verify!(request)
|
|
74
|
+
signature = extract_signature(request)
|
|
75
|
+
raise VerificationError.new('Missing signature header', reason: 'missing_signature') if signature.nil?
|
|
76
|
+
|
|
77
|
+
verify_timestamp!(request) if @timestamp_header
|
|
78
|
+
|
|
79
|
+
expected = compute_signature(request.body)
|
|
80
|
+
|
|
81
|
+
return if secure_compare(expected, signature)
|
|
82
|
+
|
|
83
|
+
raise VerificationError.new('Invalid signature', reason: 'signature_mismatch')
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns whether the verifier is properly configured.
|
|
87
|
+
#
|
|
88
|
+
# @return [Boolean] true if secret and header are present
|
|
89
|
+
def enabled?
|
|
90
|
+
!@secret.nil? && !@secret.empty? && !@header.nil? && !@header.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Configures timestamp validation options.
|
|
96
|
+
#
|
|
97
|
+
# @param timestamp_options [Hash, nil] timestamp options hash
|
|
98
|
+
def configure_timestamp_options(timestamp_options)
|
|
99
|
+
return unless timestamp_options
|
|
100
|
+
|
|
101
|
+
@timestamp_header = timestamp_options[:header]
|
|
102
|
+
@timestamp_tolerance = timestamp_options.fetch(:tolerance, 300)
|
|
103
|
+
@timestamp_format = timestamp_options.fetch(:format, :unix)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Validates the HMAC algorithm.
|
|
107
|
+
#
|
|
108
|
+
# @param algorithm [String] the algorithm name
|
|
109
|
+
# @return [String] the validated algorithm
|
|
110
|
+
# @raise [ArgumentError] if the algorithm is not supported
|
|
111
|
+
def validate_algorithm(algorithm)
|
|
112
|
+
algo = algorithm.to_s.downcase
|
|
113
|
+
unless ALGORITHMS.include?(algo)
|
|
114
|
+
raise ArgumentError, "Unsupported algorithm: #{algorithm}. Supported: #{ALGORITHMS.join(', ')}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
algo
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Extracts the signature from the request headers.
|
|
121
|
+
#
|
|
122
|
+
# @param request [Hooksmith::Request] the incoming request
|
|
123
|
+
# @return [String, nil] the extracted signature
|
|
124
|
+
def extract_signature(request)
|
|
125
|
+
raw_signature = request.header(@header)
|
|
126
|
+
return nil if raw_signature.nil? || raw_signature.empty?
|
|
127
|
+
|
|
128
|
+
signature = raw_signature.to_s
|
|
129
|
+
signature = signature.sub(/\A#{Regexp.escape(@signature_prefix)}/, '') if @signature_prefix
|
|
130
|
+
signature.strip
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Computes the expected HMAC signature for the body.
|
|
134
|
+
#
|
|
135
|
+
# @param body [String] the request body
|
|
136
|
+
# @return [String] the computed signature
|
|
137
|
+
def compute_signature(body)
|
|
138
|
+
digest = OpenSSL::HMAC.digest(@algorithm, @secret, body)
|
|
139
|
+
|
|
140
|
+
case @encoding
|
|
141
|
+
when :base64
|
|
142
|
+
Base64.strict_encode64(digest)
|
|
143
|
+
else
|
|
144
|
+
digest.unpack1('H*')
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Verifies the timestamp is within tolerance.
|
|
149
|
+
#
|
|
150
|
+
# @param request [Hooksmith::Request] the incoming request
|
|
151
|
+
# @raise [Hooksmith::VerificationError] if timestamp is invalid or expired
|
|
152
|
+
def verify_timestamp!(request)
|
|
153
|
+
timestamp_value = request.header(@timestamp_header)
|
|
154
|
+
raise VerificationError.new('Missing timestamp header', reason: 'missing_timestamp') if timestamp_value.nil?
|
|
155
|
+
|
|
156
|
+
timestamp = parse_timestamp(timestamp_value)
|
|
157
|
+
raise VerificationError.new('Invalid timestamp format', reason: 'invalid_timestamp') if timestamp.nil?
|
|
158
|
+
|
|
159
|
+
age = (Time.now - timestamp).abs
|
|
160
|
+
return unless age > @timestamp_tolerance
|
|
161
|
+
|
|
162
|
+
raise VerificationError.new(
|
|
163
|
+
"Request timestamp too old (#{age.to_i}s > #{@timestamp_tolerance}s)",
|
|
164
|
+
reason: 'timestamp_expired'
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Parses a timestamp value.
|
|
169
|
+
#
|
|
170
|
+
# @param value [String] the timestamp value
|
|
171
|
+
# @return [Time, nil] the parsed time or nil if invalid
|
|
172
|
+
def parse_timestamp(value)
|
|
173
|
+
case @timestamp_format
|
|
174
|
+
when :iso8601
|
|
175
|
+
Time.iso8601(value)
|
|
176
|
+
else
|
|
177
|
+
Time.at(value.to_i)
|
|
178
|
+
end
|
|
179
|
+
rescue ArgumentError, TypeError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
data/lib/hooksmith/version.rb
CHANGED
data/lib/hooksmith.rb
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'hooksmith/version'
|
|
4
|
+
require 'hooksmith/errors'
|
|
4
5
|
require 'hooksmith/configuration'
|
|
5
6
|
require 'hooksmith/config/provider'
|
|
6
7
|
require 'hooksmith/config/event_store'
|
|
8
|
+
require 'hooksmith/request'
|
|
9
|
+
require 'hooksmith/instrumentation'
|
|
7
10
|
require 'hooksmith/dispatcher'
|
|
8
11
|
require 'hooksmith/logger'
|
|
9
12
|
require 'hooksmith/event_recorder'
|
|
13
|
+
require 'hooksmith/idempotency'
|
|
10
14
|
require 'hooksmith/processor/base'
|
|
11
|
-
require 'hooksmith/
|
|
15
|
+
require 'hooksmith/jobs/dispatcher_job' if defined?(ActiveJob)
|
|
16
|
+
require 'hooksmith/verifiers/base'
|
|
17
|
+
require 'hooksmith/verifiers/hmac'
|
|
18
|
+
require 'hooksmith/verifiers/bearer_token'
|
|
19
|
+
|
|
20
|
+
if defined?(Rails)
|
|
21
|
+
require 'hooksmith/railtie'
|
|
22
|
+
require 'hooksmith/rails/webhooks_controller'
|
|
23
|
+
end
|
|
12
24
|
|
|
13
25
|
# Main entry point for the Hooksmith gem.
|
|
14
26
|
#
|
|
@@ -21,6 +33,22 @@ require 'hooksmith/railtie' if defined?(Rails)
|
|
|
21
33
|
#
|
|
22
34
|
# Hooksmith::Dispatcher.new(provider: :stripe, event: :charge_succeeded, payload: payload).run!
|
|
23
35
|
#
|
|
36
|
+
# @example With request verification:
|
|
37
|
+
# Hooksmith.configure do |config|
|
|
38
|
+
# config.provider(:stripe) do |stripe|
|
|
39
|
+
# stripe.verifier = Hooksmith::Verifiers::Hmac.new(
|
|
40
|
+
# secret: ENV['STRIPE_WEBHOOK_SECRET'],
|
|
41
|
+
# header: 'Stripe-Signature'
|
|
42
|
+
# )
|
|
43
|
+
# stripe.register(:charge_succeeded, MyStripeProcessor)
|
|
44
|
+
# end
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# # In your controller:
|
|
48
|
+
# request = Hooksmith::Request.new(headers: request.headers, body: request.raw_post)
|
|
49
|
+
# Hooksmith.verify!(provider: :stripe, request: request)
|
|
50
|
+
# Hooksmith::Dispatcher.new(provider: :stripe, event: event, payload: payload).run!
|
|
51
|
+
#
|
|
24
52
|
module Hooksmith
|
|
25
53
|
# Returns the configuration instance.
|
|
26
54
|
# @return [Configuration] the configuration instance.
|
|
@@ -39,4 +67,26 @@ module Hooksmith
|
|
|
39
67
|
def self.logger
|
|
40
68
|
Logger.instance
|
|
41
69
|
end
|
|
70
|
+
|
|
71
|
+
# Verifies an incoming webhook request for a provider.
|
|
72
|
+
#
|
|
73
|
+
# @param provider [Symbol, String] the provider name
|
|
74
|
+
# @param request [Hooksmith::Request] the incoming request
|
|
75
|
+
# @raise [Hooksmith::VerificationError] if verification fails
|
|
76
|
+
# @return [void]
|
|
77
|
+
def self.verify!(provider:, request:)
|
|
78
|
+
verifier = configuration.verifier_for(provider)
|
|
79
|
+
return unless verifier&.enabled?
|
|
80
|
+
|
|
81
|
+
verifier.verify!(request)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Checks if a provider has a verifier configured.
|
|
85
|
+
#
|
|
86
|
+
# @param provider [Symbol, String] the provider name
|
|
87
|
+
# @return [Boolean] true if a verifier is configured
|
|
88
|
+
def self.verifier_configured?(provider)
|
|
89
|
+
verifier = configuration.verifier_for(provider)
|
|
90
|
+
verifier&.enabled? || false
|
|
91
|
+
end
|
|
42
92
|
end
|