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,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hooksmith
|
|
4
|
+
# Provides ActiveSupport::Notifications instrumentation for webhook processing.
|
|
5
|
+
#
|
|
6
|
+
# This module emits events at key points in the webhook lifecycle, enabling
|
|
7
|
+
# metrics collection, tracing, and debugging without modifying core code.
|
|
8
|
+
#
|
|
9
|
+
# @example Subscribe to all Hooksmith events
|
|
10
|
+
# ActiveSupport::Notifications.subscribe(/hooksmith/) do |name, start, finish, id, payload|
|
|
11
|
+
# duration = finish - start
|
|
12
|
+
# Rails.logger.info "#{name} took #{duration}s"
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# @example Subscribe to specific events
|
|
16
|
+
# ActiveSupport::Notifications.subscribe('dispatch.hooksmith') do |*args|
|
|
17
|
+
# event = ActiveSupport::Notifications::Event.new(*args)
|
|
18
|
+
# StatsD.timing('hooksmith.dispatch', event.duration, tags: ["provider:#{event.payload[:provider]}"])
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# == Available Events
|
|
22
|
+
#
|
|
23
|
+
# * `dispatch.hooksmith` - Emitted when a webhook is dispatched
|
|
24
|
+
# - payload: { provider:, event:, payload:, processor:, result: }
|
|
25
|
+
#
|
|
26
|
+
# * `process.hooksmith` - Emitted when a processor executes
|
|
27
|
+
# - payload: { provider:, event:, processor:, result: }
|
|
28
|
+
#
|
|
29
|
+
# * `no_processor.hooksmith` - Emitted when no processor matches
|
|
30
|
+
# - payload: { provider:, event: }
|
|
31
|
+
#
|
|
32
|
+
# * `multiple_processors.hooksmith` - Emitted when multiple processors match
|
|
33
|
+
# - payload: { provider:, event:, processor_count: }
|
|
34
|
+
#
|
|
35
|
+
# * `error.hooksmith` - Emitted when an error occurs
|
|
36
|
+
# - payload: { provider:, event:, error:, error_class: }
|
|
37
|
+
#
|
|
38
|
+
module Instrumentation
|
|
39
|
+
NAMESPACE = 'hooksmith'
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# Instruments a block with the given event name.
|
|
44
|
+
#
|
|
45
|
+
# @param event_name [String] the event name (without namespace)
|
|
46
|
+
# @param payload [Hash] the event payload
|
|
47
|
+
# @yield the block to instrument
|
|
48
|
+
# @return [Object] the result of the block
|
|
49
|
+
def instrument(event_name, payload = {}, &block)
|
|
50
|
+
return yield unless notifications_available?
|
|
51
|
+
|
|
52
|
+
full_name = "#{event_name}.#{NAMESPACE}"
|
|
53
|
+
ActiveSupport::Notifications.instrument(full_name, payload, &block)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Publishes an event without a block.
|
|
57
|
+
#
|
|
58
|
+
# @param event_name [String] the event name (without namespace)
|
|
59
|
+
# @param payload [Hash] the event payload
|
|
60
|
+
def publish(event_name, payload = {})
|
|
61
|
+
return unless notifications_available?
|
|
62
|
+
|
|
63
|
+
full_name = "#{event_name}.#{NAMESPACE}"
|
|
64
|
+
ActiveSupport::Notifications.publish(full_name, payload)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Checks if ActiveSupport::Notifications is available.
|
|
68
|
+
#
|
|
69
|
+
# @return [Boolean] true if available
|
|
70
|
+
def notifications_available?
|
|
71
|
+
defined?(ActiveSupport::Notifications)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Subscribes to a Hooksmith event.
|
|
75
|
+
#
|
|
76
|
+
# @param event_name [String, Regexp, nil] the event name, pattern, or nil for all events
|
|
77
|
+
# @yield [name, start, finish, id, payload] the event callback
|
|
78
|
+
# @return [Object] the subscription object
|
|
79
|
+
def subscribe(event_name = nil, &block)
|
|
80
|
+
return unless notifications_available?
|
|
81
|
+
|
|
82
|
+
pattern = build_subscription_pattern(event_name)
|
|
83
|
+
ActiveSupport::Notifications.subscribe(pattern, &block)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Unsubscribes from a Hooksmith event.
|
|
87
|
+
#
|
|
88
|
+
# @param subscriber [Object] the subscription object from subscribe
|
|
89
|
+
def unsubscribe(subscriber)
|
|
90
|
+
return unless notifications_available?
|
|
91
|
+
|
|
92
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Builds the subscription pattern based on the event name type.
|
|
98
|
+
#
|
|
99
|
+
# @param event_name [String, Regexp, nil] the event name or pattern
|
|
100
|
+
# @return [String, Regexp] the subscription pattern
|
|
101
|
+
def build_subscription_pattern(event_name)
|
|
102
|
+
case event_name
|
|
103
|
+
when nil
|
|
104
|
+
/\.#{NAMESPACE}$/
|
|
105
|
+
when Regexp
|
|
106
|
+
event_name
|
|
107
|
+
else
|
|
108
|
+
"#{event_name}.#{NAMESPACE}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hooksmith
|
|
4
|
+
module Jobs
|
|
5
|
+
# ActiveJob for asynchronous webhook processing.
|
|
6
|
+
#
|
|
7
|
+
# This job wraps the Dispatcher to process webhooks in the background,
|
|
8
|
+
# allowing your webhook endpoint to respond quickly while processing
|
|
9
|
+
# happens asynchronously.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage in a controller
|
|
12
|
+
# class WebhooksController < ApplicationController
|
|
13
|
+
# def stripe
|
|
14
|
+
# Hooksmith::Jobs::DispatcherJob.perform_later(
|
|
15
|
+
# provider: 'stripe',
|
|
16
|
+
# event: params[:type],
|
|
17
|
+
# payload: params.to_unsafe_h
|
|
18
|
+
# )
|
|
19
|
+
# head :ok
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example With custom queue
|
|
24
|
+
# Hooksmith::Jobs::DispatcherJob.set(queue: :webhooks).perform_later(...)
|
|
25
|
+
#
|
|
26
|
+
# @example With retry configuration (in your application)
|
|
27
|
+
# # config/initializers/hooksmith.rb
|
|
28
|
+
# Hooksmith::Jobs::DispatcherJob.retry_on StandardError, wait: :polynomially_longer, attempts: 5
|
|
29
|
+
#
|
|
30
|
+
class DispatcherJob < ActiveJob::Base
|
|
31
|
+
queue_as :default
|
|
32
|
+
|
|
33
|
+
# Performs the webhook dispatch asynchronously.
|
|
34
|
+
#
|
|
35
|
+
# @param provider [String, Symbol] the webhook provider name
|
|
36
|
+
# @param event [String, Symbol] the event type
|
|
37
|
+
# @param payload [Hash] the webhook payload
|
|
38
|
+
# @param options [Hash] additional options
|
|
39
|
+
# @option options [Boolean] :skip_idempotency_check (false) skip duplicate checking
|
|
40
|
+
# @return [Object] the result from the processor
|
|
41
|
+
def perform(provider:, event:, payload:, **options)
|
|
42
|
+
provider = provider.to_s
|
|
43
|
+
event = event.to_s
|
|
44
|
+
|
|
45
|
+
if check_idempotency?(options)
|
|
46
|
+
key = Hooksmith::Idempotency.extract_key(provider:, payload:)
|
|
47
|
+
if key && Hooksmith::Idempotency.already_processed?(provider:, key:)
|
|
48
|
+
Hooksmith.logger.info("Skipping duplicate webhook: #{provider}/#{event} (key=#{key})")
|
|
49
|
+
return nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Hooksmith::Dispatcher.new(provider:, event:, payload:).run!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def check_idempotency?(options)
|
|
59
|
+
!options[:skip_idempotency_check]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -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
|