hooksmith 0.1.2 → 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 +319 -42
- data/lib/hooksmith/config/event_store.rb +47 -0
- data/lib/hooksmith/config/provider.rb +47 -0
- data/lib/hooksmith/configuration.rb +62 -23
- data/lib/hooksmith/dispatcher.rb +74 -29
- data/lib/hooksmith/errors.rb +240 -0
- data/lib/hooksmith/event_recorder.rb +54 -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 +54 -1
- metadata +35 -4
- data/hooksmith-0.1.0.gem +0 -0
|
@@ -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,11 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'hooksmith/version'
|
|
4
|
+
require 'hooksmith/errors'
|
|
4
5
|
require 'hooksmith/configuration'
|
|
6
|
+
require 'hooksmith/config/provider'
|
|
7
|
+
require 'hooksmith/config/event_store'
|
|
8
|
+
require 'hooksmith/request'
|
|
9
|
+
require 'hooksmith/instrumentation'
|
|
5
10
|
require 'hooksmith/dispatcher'
|
|
6
11
|
require 'hooksmith/logger'
|
|
12
|
+
require 'hooksmith/event_recorder'
|
|
13
|
+
require 'hooksmith/idempotency'
|
|
7
14
|
require 'hooksmith/processor/base'
|
|
8
|
-
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
|
|
9
24
|
|
|
10
25
|
# Main entry point for the Hooksmith gem.
|
|
11
26
|
#
|
|
@@ -18,6 +33,22 @@ require 'hooksmith/railtie' if defined?(Rails)
|
|
|
18
33
|
#
|
|
19
34
|
# Hooksmith::Dispatcher.new(provider: :stripe, event: :charge_succeeded, payload: payload).run!
|
|
20
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
|
+
#
|
|
21
52
|
module Hooksmith
|
|
22
53
|
# Returns the configuration instance.
|
|
23
54
|
# @return [Configuration] the configuration instance.
|
|
@@ -36,4 +67,26 @@ module Hooksmith
|
|
|
36
67
|
def self.logger
|
|
37
68
|
Logger.instance
|
|
38
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
|
|
39
92
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hooksmith
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- gregoryrivage
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2025-
|
|
11
|
-
dependencies:
|
|
10
|
+
date: 2025-12-25 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
- - ">="
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: 0.1.0
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - "~>"
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '0.1'
|
|
29
|
+
- - ">="
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: 0.1.0
|
|
12
32
|
description: Hooksmith is a gem that allows you to handle webhooks in your Rails application.
|
|
13
33
|
It provides a simple and flexible way to receive, validate, and process webhooks
|
|
14
34
|
from various services. With Hooksmith, you can easily configure webhook endpoints,
|
|
@@ -27,13 +47,24 @@ files:
|
|
|
27
47
|
- LICENSE.txt
|
|
28
48
|
- README.md
|
|
29
49
|
- Rakefile
|
|
30
|
-
- hooksmith-0.1.0.gem
|
|
31
50
|
- lib/hooksmith.rb
|
|
51
|
+
- lib/hooksmith/config/event_store.rb
|
|
52
|
+
- lib/hooksmith/config/provider.rb
|
|
32
53
|
- lib/hooksmith/configuration.rb
|
|
33
54
|
- lib/hooksmith/dispatcher.rb
|
|
55
|
+
- lib/hooksmith/errors.rb
|
|
56
|
+
- lib/hooksmith/event_recorder.rb
|
|
57
|
+
- lib/hooksmith/idempotency.rb
|
|
58
|
+
- lib/hooksmith/instrumentation.rb
|
|
59
|
+
- lib/hooksmith/jobs/dispatcher_job.rb
|
|
34
60
|
- lib/hooksmith/logger.rb
|
|
35
61
|
- lib/hooksmith/processor/base.rb
|
|
62
|
+
- lib/hooksmith/rails/webhooks_controller.rb
|
|
36
63
|
- lib/hooksmith/railtie.rb
|
|
64
|
+
- lib/hooksmith/request.rb
|
|
65
|
+
- lib/hooksmith/verifiers/base.rb
|
|
66
|
+
- lib/hooksmith/verifiers/bearer_token.rb
|
|
67
|
+
- lib/hooksmith/verifiers/hmac.rb
|
|
37
68
|
- lib/hooksmith/version.rb
|
|
38
69
|
- sig/hooksmith.rbs
|
|
39
70
|
homepage: https://github.com/gregoryrivage/hooksmith
|
data/hooksmith-0.1.0.gem
DELETED
|
Binary file
|