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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hooksmith
4
- VERSION = '0.1.2'
4
+ VERSION = '1.0.0'
5
5
  end
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/railtie' if defined?(Rails)
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.1.2
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-03-13 00:00:00.000000000 Z
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