axn-webhooks 0.1.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 +7 -0
- data/CHANGELOG.md +176 -0
- data/DESIGN-NOTES.md +241 -0
- data/LICENSE.txt +21 -0
- data/README.md +1042 -0
- data/lib/axn/webhooks/dispatch.rb +129 -0
- data/lib/axn/webhooks/errors.rb +48 -0
- data/lib/axn/webhooks/handler.rb +15 -0
- data/lib/axn/webhooks/header_value.rb +29 -0
- data/lib/axn/webhooks/inbound/build_request.rb +23 -0
- data/lib/axn/webhooks/inbound/challenge.rb +37 -0
- data/lib/axn/webhooks/inbound/challenge_required.rb +35 -0
- data/lib/axn/webhooks/inbound/dsl.rb +240 -0
- data/lib/axn/webhooks/inbound/endpoint.rb +221 -0
- data/lib/axn/webhooks/inbound/parsers.rb +20 -0
- data/lib/axn/webhooks/inbound/respond_context.rb +17 -0
- data/lib/axn/webhooks/inbound/router.rb +104 -0
- data/lib/axn/webhooks/inbound.rb +124 -0
- data/lib/axn/webhooks/outbound/callable_arity.rb +99 -0
- data/lib/axn/webhooks/outbound/config.rb +442 -0
- data/lib/axn/webhooks/outbound/deliver.rb +425 -0
- data/lib/axn/webhooks/outbound/dsl.rb +121 -0
- data/lib/axn/webhooks/outbound/emit.rb +181 -0
- data/lib/axn/webhooks/outbound/envelope.rb +23 -0
- data/lib/axn/webhooks/outbound/signer.rb +376 -0
- data/lib/axn/webhooks/outbound/subscriber.rb +152 -0
- data/lib/axn/webhooks/outbound/target_policy.rb +135 -0
- data/lib/axn/webhooks/outbound/transport.rb +59 -0
- data/lib/axn/webhooks/outbound.rb +73 -0
- data/lib/axn/webhooks/request.rb +230 -0
- data/lib/axn/webhooks/resolvers.rb +43 -0
- data/lib/axn/webhooks/respond.rb +26 -0
- data/lib/axn/webhooks/response.rb +116 -0
- data/lib/axn/webhooks/signature.rb +268 -0
- data/lib/axn/webhooks/static_respond.rb +22 -0
- data/lib/axn/webhooks/vendor_facet.rb +25 -0
- data/lib/axn/webhooks/verifiers/basic_auth.rb +128 -0
- data/lib/axn/webhooks/verifiers/hmac.rb +58 -0
- data/lib/axn/webhooks/verifiers/standard_webhooks.rb +129 -0
- data/lib/axn/webhooks/verifiers.rb +50 -0
- data/lib/axn/webhooks/verify.rb +106 -0
- data/lib/axn/webhooks/version.rb +7 -0
- data/lib/axn/webhooks.rb +61 -0
- data/lib/axn-webhooks.rb +3 -0
- metadata +128 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Outbound
|
|
6
|
+
# Resolves an event's subscribers and enqueues one Deliver per target. Built as an Axn so an
|
|
7
|
+
# unknown event (a typo) is a loud, reported failure instead of today's silent no-op.
|
|
8
|
+
class Emit
|
|
9
|
+
include Axn
|
|
10
|
+
include Axn::Webhooks::VendorFacet
|
|
11
|
+
|
|
12
|
+
expects :event
|
|
13
|
+
# sensitive: this is the caller's own event payload — routinely PII (the whole point of a
|
|
14
|
+
# webhook is shipping domain data), and axn's auto-logging would otherwise render it into
|
|
15
|
+
# the application log on every emit. Matches `Deliver`'s `body`, which is this serialized.
|
|
16
|
+
expects :data, type: Hash, allow_blank: true, default: {}, sensitive: true
|
|
17
|
+
|
|
18
|
+
# Per-call overrides (both nil = declaration-time behavior). `to:` is a URL String or an
|
|
19
|
+
# Array of them; `async:` is a tri-state — nil (:auto), true (demand async), false (force
|
|
20
|
+
# sync). `allow_blank` on both: nil is the "not given" signal, and `false` is blank too.
|
|
21
|
+
expects :to, allow_blank: true, default: nil
|
|
22
|
+
# `type: :boolean` is the tri-state guard: without it a config-derived `async: "false"` is
|
|
23
|
+
# truthy and demands async — the exact opposite of what the caller asked for (Codex review).
|
|
24
|
+
expects :async, type: :boolean, allow_nil: true, default: nil
|
|
25
|
+
|
|
26
|
+
# Kept for back-compat -- now derived from `deliveries` rather than being its own tally,
|
|
27
|
+
# so ordering is documented rather than incidental (`webhook_ids == deliveries.map { |d|
|
|
28
|
+
# d[:webhook_id] }`).
|
|
29
|
+
exposes :webhook_ids, type: Array, allow_blank: true, default: []
|
|
30
|
+
# Rows actually ENQUEUED, not rows resolved -- a malformed row is now caught by
|
|
31
|
+
# `resolve_targets`/`TargetPolicy` before it ever reaches here (see `rejected_count` below),
|
|
32
|
+
# where before it got a webhook_id and was counted despite `Deliver` immediately failing its
|
|
33
|
+
# own `expects :url, type: String` validation.
|
|
34
|
+
exposes :target_count, type: Integer, default: 0
|
|
35
|
+
|
|
36
|
+
# Sync fallback only: how many of `target_count` deliveries came back failed. ALWAYS 0 on
|
|
37
|
+
# the async path — nothing has failed at emit time there; failures happen later, and
|
|
38
|
+
# `Deliver` reports them itself (exhaustion via on_exception, a permanent 4xx via its own
|
|
39
|
+
# result). `nil`-when-async would be more honest AND would turn every
|
|
40
|
+
# `result.failed_count > 0` into a NoMethodError, so the footgun costs more than the
|
|
41
|
+
# precision buys.
|
|
42
|
+
exposes :failed_count, type: Integer, default: 0
|
|
43
|
+
|
|
44
|
+
# `{ webhook_id:, url:, subscriber_id: }` per enqueued target -- the correlation a DB-backed
|
|
45
|
+
# sender wants to persist one delivery record per subscription, without re-resolving
|
|
46
|
+
# `subscribers`/`to:` and trusting undocumented ordering.
|
|
47
|
+
exposes :deliveries, type: Array, allow_blank: true, default: []
|
|
48
|
+
# Rows `TargetPolicy` refused (a malformed row, or one the declared `allowed_hosts`/
|
|
49
|
+
# `allow_url` policy rejects) -- collected rather than discarded, so a bad row never
|
|
50
|
+
# silently disappears from what `target_count` used to (over)count. `emit` still reports
|
|
51
|
+
# `ok?` (the good rows really were enqueued); a caller that cares checks `rejected_count`.
|
|
52
|
+
exposes :rejected_count, type: Integer, default: 0
|
|
53
|
+
exposes :rejected, type: Array, allow_blank: true, default: []
|
|
54
|
+
|
|
55
|
+
# Bounded to the events a sending app declares — same shape as inbound's unconditional
|
|
56
|
+
# `reason` dimension, not a per-request identity.
|
|
57
|
+
dimension :event, -> { event.to_s }
|
|
58
|
+
|
|
59
|
+
def call
|
|
60
|
+
config = Axn::Webhooks::Outbound.config
|
|
61
|
+
type = config.wire_type(event)
|
|
62
|
+
use_async = async?
|
|
63
|
+
# Only the :auto path is a DEGRADED mode worth warning about — an explicit `async: false`
|
|
64
|
+
# is the caller getting exactly what they asked for.
|
|
65
|
+
warn_sync_fallback(type) if async.nil? && !use_async
|
|
66
|
+
|
|
67
|
+
resolution = resolve_targets(config)
|
|
68
|
+
report_rejections(resolution.rejections) if resolution.rejections.any?
|
|
69
|
+
|
|
70
|
+
deliveries = []
|
|
71
|
+
failed = 0
|
|
72
|
+
resolution.subscribers.each do |subscriber|
|
|
73
|
+
id = Envelope.new_id
|
|
74
|
+
body = Envelope.build(id:, type:, data:)
|
|
75
|
+
delivered = enqueue(use_async, url: subscriber.url, webhook_id: id, body:, event: type, vendor:,
|
|
76
|
+
subscriber_id: subscriber.id)
|
|
77
|
+
failed += 1 if delivered && !delivered.ok?
|
|
78
|
+
deliveries << { webhook_id: id, url: subscriber.url, subscriber_id: subscriber.id }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
expose(webhook_ids: deliveries.map { |d| d[:webhook_id] }, target_count: deliveries.size, failed_count: failed,
|
|
82
|
+
deliveries:, rejected_count: resolution.rejections.size, rejected: resolution.rejections)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Overrides the plain `expects :vendor` reader VendorFacet declared above. Computed FRESH on
|
|
88
|
+
# every call (not memoized into an ivar set inside `#call`): axn resolves `dimension`/`tag`
|
|
89
|
+
# facets input-phase, i.e. eagerly BEFORE the body runs, so a value only set inside `#call`
|
|
90
|
+
# would still read as unset there — Emit's own `:vendor` dimension/tag would stamp nil even
|
|
91
|
+
# though the identical lookup, threaded down to `Deliver`, stamps correctly (Codex P2
|
|
92
|
+
# finding). Reading `config.vendor_for(event)` here still keeps `fetch`'s unknown-event raise
|
|
93
|
+
# inside axn's executor (whichever facet-resolution or body call reaches it first is already
|
|
94
|
+
# running under axn's own exception-reporting boundary) — the ordering `Axn::Webhooks.emit`'s
|
|
95
|
+
# comment cares about is never resolving this ahead of `Emit.call!` itself.
|
|
96
|
+
def vendor = Axn::Webhooks::Outbound.config.vendor_for(event)
|
|
97
|
+
|
|
98
|
+
# Async when an adapter is configured for Deliver, else a warned best-effort sync fallback
|
|
99
|
+
# (no cross-process retries). Presence check only — never branches on adapter type.
|
|
100
|
+
# Returns Deliver's own result on the sync path, or nil when the delivery was ENQUEUED —
|
|
101
|
+
# an enqueue can't know the eventual outcome, so there is no result to report and nothing
|
|
102
|
+
# to count as failed (see the `failed_count` exposure above).
|
|
103
|
+
def enqueue(use_async, **)
|
|
104
|
+
if use_async
|
|
105
|
+
Deliver.call_async(**)
|
|
106
|
+
nil
|
|
107
|
+
else
|
|
108
|
+
Deliver.call(**)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A per-call `to:` REPLACES resolution entirely — the declared `to:`/`subscribers` is not
|
|
113
|
+
# consulted and not appended to, the same no-silent-merge stance Config#resolve_subscribers
|
|
114
|
+
# takes for a declared resolver that returns nil. Validated here rather than at boot (it
|
|
115
|
+
# can't be known earlier) and as an Axn::Webhooks::Error, not the ArgumentError a declaration
|
|
116
|
+
# mistake gets: this one is raised per call, on caller-supplied runtime data. Goes through
|
|
117
|
+
# the SAME TargetPolicy (including the declared `allowed_hosts`/`allow_url`) as a declared
|
|
118
|
+
# `to:`/`subscribers` resolver -- a one-off override is not a way around the host policy.
|
|
119
|
+
# Any rejection here raises immediately rather than being collected: the caller supplied
|
|
120
|
+
# exactly this URL, on purpose, this one call; a typo deserves an immediate raise; unlike a
|
|
121
|
+
# DB-backed resolver returning many rows where one bad one shouldn't sink the rest.
|
|
122
|
+
def resolve_targets(config)
|
|
123
|
+
return config.resolve_subscribers(event) if to.nil?
|
|
124
|
+
|
|
125
|
+
subscribers = Array(to).map do |target|
|
|
126
|
+
TargetPolicy.check!(target, allowed_hosts: config.allowed_hosts, allow_url: config.allow_url)
|
|
127
|
+
rescue Axn::Webhooks::InvalidTarget => e
|
|
128
|
+
raise Axn::Webhooks::Error, "emit(#{event.inspect}, to:) #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
Config::Resolution.new(subscribers:, rejections: [])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Reported ONCE per emit (a single fact: "N rows were rejected"), not once per rejected row
|
|
134
|
+
# -- the same once-per-emit discipline `warn_sync_fallback` already follows. Best-effort: a
|
|
135
|
+
# broken reporter must not turn "some rows were bad" into a crash of an otherwise-successful
|
|
136
|
+
# emit (the same reasoning Deliver's own `report_exhaustion` documents).
|
|
137
|
+
def report_rejections(rejections)
|
|
138
|
+
Axn::Extensions.best_effort("reporting rejected outbound targets", action: self) do
|
|
139
|
+
Axn.config.on_exception(
|
|
140
|
+
Axn::Webhooks::Error.new("#{rejections.size} outbound target(s) rejected for #{event}"),
|
|
141
|
+
action: self,
|
|
142
|
+
context: { event: event.to_s, rejections: },
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Tri-state. An explicit `true` with no adapter RAISES rather than degrading: a missing
|
|
148
|
+
# adapter falls back to sync only under `:auto`, never under an explicit request (the same
|
|
149
|
+
# rule inbound's Dispatch#dispatch_async enforces, for the same reason — something marked
|
|
150
|
+
# async usually is so because running it inline would blow someone's time budget).
|
|
151
|
+
def async?
|
|
152
|
+
return async_configured? if async.nil?
|
|
153
|
+
return false unless async
|
|
154
|
+
|
|
155
|
+
unless async_configured?
|
|
156
|
+
raise Axn::Webhooks::Error,
|
|
157
|
+
"emit(#{event.inspect}, async: true) requires an axn async adapter, but none is " \
|
|
158
|
+
"configured for #{Deliver} (add `async :sidekiq`/`async :active_job` to it, or set a global default)"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Warned ONCE per emit (not once per target) — a high-fan-out event would otherwise spam
|
|
165
|
+
# one line per subscriber for what is a single configuration fact.
|
|
166
|
+
def warn_sync_fallback(type)
|
|
167
|
+
Axn.config.logger.warn(
|
|
168
|
+
"[axn-webhooks] delivering #{type} synchronously (no async adapter configured) — " \
|
|
169
|
+
"best-effort, no cross-process retries",
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def async_configured?
|
|
174
|
+
return !!Deliver._async_adapter if Deliver.respond_to?(:_async_adapter) && !Deliver._async_adapter.nil?
|
|
175
|
+
|
|
176
|
+
Axn.config.default_async?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Axn
|
|
7
|
+
module Webhooks
|
|
8
|
+
module Outbound
|
|
9
|
+
# Builds the Standard Webhooks message body and its idempotency id. The body is fixed at
|
|
10
|
+
# emit time (part of the dedup identity); the SIGNATURE is recomputed per delivery attempt
|
|
11
|
+
# (see Deliver), so this carries no signing concern.
|
|
12
|
+
module Envelope
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def new_id = "msg_#{SecureRandom.uuid}"
|
|
16
|
+
|
|
17
|
+
def build(id:, type:, data:, now: Time.now)
|
|
18
|
+
JSON.generate(id:, timestamp: now.to_i, type: type.to_s, data:)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Outbound
|
|
6
|
+
# Builds a signer callable (#call(id:, timestamp:, body:) -> header Hash) from a `sign`
|
|
7
|
+
# declaration. The :standard_webhooks strategy is the outbound face of the inbound
|
|
8
|
+
# verify :standard_webhooks — same scheme, so a receiver using that verifier accepts it.
|
|
9
|
+
module Signer
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# RFC 7230 field-name token. Net::HTTP stores whatever key it is handed and serializes it
|
|
13
|
+
# straight into the header line, so a space yields a malformed request and a newline
|
|
14
|
+
# appends attacker-shaped wire headers — neither caught until delivery (Codex review).
|
|
15
|
+
# Module-level (not nested under HmacSigner) so `Deliver`'s per-destination `headers`
|
|
16
|
+
# merge (PRO-3214) can hold a subscriber-supplied header name to the SAME grammar, rather
|
|
17
|
+
# than duplicating it — the identical injection risk, just from runtime data instead of a
|
|
18
|
+
# `sign :hmac` declaration (Codex P1 finding).
|
|
19
|
+
HEADER_NAME = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
20
|
+
|
|
21
|
+
def build(strategy:, opts:, block:)
|
|
22
|
+
return CustomSigner.new(block) if block
|
|
23
|
+
|
|
24
|
+
case strategy&.to_sym
|
|
25
|
+
when :hmac then HmacSigner.new(**opts)
|
|
26
|
+
when :standard_webhooks then StandardWebhooksSigner.new(**opts)
|
|
27
|
+
# A pure declaration mistake (decided once at boot, never at runtime) — ArgumentError, not
|
|
28
|
+
# Axn::Webhooks::Error, matching Config's own misconfiguration-vs-runtime split.
|
|
29
|
+
else raise ArgumentError, "unknown sign strategy #{strategy.inspect}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Wraps a user block; called with the same kwargs as the built-in signers, PLUS `subscriber:`
|
|
34
|
+
# (PRO-3214, a `Subscriber` or nil) -- filtered down to whatever the block actually declares
|
|
35
|
+
# (via CallableArity.accepted_keywords), so a block written against the original
|
|
36
|
+
# `(id:, timestamp:, body:)` contract keeps working byte-for-byte rather than raising an
|
|
37
|
+
# unexpected-keyword ArgumentError the moment a widened caller starts also offering
|
|
38
|
+
# `subscriber:`. A block declaring `**` receives everything unfiltered.
|
|
39
|
+
class CustomSigner
|
|
40
|
+
# The only kwargs a signer is ever called with. A block may ignore any/all of them --
|
|
41
|
+
# `sign { { "X-API-Key" => key } }` (zero params) is a legitimate, pre-existing pattern:
|
|
42
|
+
# Ruby blocks are always non-lambda Procs, which silently tolerate being called with
|
|
43
|
+
# kwargs they never declared (Codex P1 finding: an earlier version of this check
|
|
44
|
+
# REQUIRED every block to declare id:/timestamp:/body:, rejecting that working
|
|
45
|
+
# configuration at boot even though it was never actually broken).
|
|
46
|
+
SUPPLIED_KEYWORDS = %i[id timestamp body subscriber].freeze
|
|
47
|
+
|
|
48
|
+
def initialize(block)
|
|
49
|
+
@block = block
|
|
50
|
+
@accepted = CallableArity.accepted_keywords(block)
|
|
51
|
+
# A block declaring NO keywords at all but at least one POSITIONAL param (`sign { |options|
|
|
52
|
+
# … }`) is the historical "options Hash" shape: Ruby auto-converts trailing keyword
|
|
53
|
+
# arguments into a Hash for a lone positional parameter (true for both a Proc/block and a
|
|
54
|
+
# lambda alike), which is exactly what the ORIGINAL unconditional `.call(id:, timestamp:,
|
|
55
|
+
# body:)` relied on. Filtering down to `**{}` (zero keywords accepted) calls with ZERO
|
|
56
|
+
# arguments instead -- fine for a Proc (leaves `options` nil, tolerated) but a REQUIRED-arity
|
|
57
|
+
# lambda raises outright; either way `options` never gets the data (Codex P1 finding).
|
|
58
|
+
@wants_positional_hash = @accepted != :all && @accepted.empty? && CallableArity.accepts_positional?(block)
|
|
59
|
+
validate_required_keywords!
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def call(id:, timestamp:, body:, subscriber: nil)
|
|
63
|
+
# The positional-Hash shape's CONTRACT is exactly these three keys -- it has no way to
|
|
64
|
+
# OPT IN to `subscriber:` the way a keyword-declaring block does, so it must never see
|
|
65
|
+
# it: a pre-existing signer deriving its signature from the WHOLE hash (e.g. hashing
|
|
66
|
+
# every key-value pair together) would compute a DIFFERENT signature the instant a 4th
|
|
67
|
+
# key appeared, silently breaking verification on the receiving end (Codex P1 finding).
|
|
68
|
+
return @block.call({ id:, timestamp:, body: }) if @wants_positional_hash
|
|
69
|
+
|
|
70
|
+
@block.call(**filtered({ id:, timestamp:, body:, subscriber: }))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def filtered(kwargs)
|
|
76
|
+
return kwargs if @accepted == :all
|
|
77
|
+
|
|
78
|
+
kwargs.slice(*@accepted)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The one shape that genuinely fails on every call: a block REQUIRING a keyword outside
|
|
82
|
+
# `SUPPLIED_KEYWORDS` (e.g. `sign { |id:, vendor:| … }`) -- `vendor:` is never one of the
|
|
83
|
+
# kwargs a signer is called with, so every real signing attempt would raise "missing
|
|
84
|
+
# keyword: vendor". A block that merely ignores some/all of id:/timestamp:/body:/
|
|
85
|
+
# subscriber: -- including declaring NONE of them -- is fine; Ruby's own Proc/block
|
|
86
|
+
# semantics already tolerate that.
|
|
87
|
+
def validate_required_keywords!
|
|
88
|
+
# No `@accepted == :all` early-return: `**` only absorbs EXTRA/unknown keywords, it
|
|
89
|
+
# does nothing for a REQUIRED one this gem still never supplies -- `->(id:, vendor:,
|
|
90
|
+
# **) { }` reported :all (skipping this check entirely) but still raised "missing
|
|
91
|
+
# keyword: vendor" on the very first real call (Codex P2 finding, round 11).
|
|
92
|
+
# `required_keywords` inspects the block's OWN declared params directly and is
|
|
93
|
+
# unaffected by whether it ALSO double-splats.
|
|
94
|
+
unsupplied = CallableArity.required_keywords(@block) - SUPPLIED_KEYWORDS
|
|
95
|
+
return if unsupplied.empty?
|
|
96
|
+
|
|
97
|
+
raise ArgumentError,
|
|
98
|
+
"sign block requires #{unsupplied.map { |k| "#{k}:" }.join(', ')}, which this gem " \
|
|
99
|
+
"never supplies (only #{SUPPLIED_KEYWORDS.map { |k| "#{k}:" }.join(', ')} are ever passed)"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Parametric HMAC, the outbound face of `verify :hmac`. Emits ONE signature header plus an
|
|
104
|
+
# optional timestamp header. `header:` is required: unlike Standard Webhooks there is no
|
|
105
|
+
# universal header name, which is exactly why the inbound verifier requires `signature:`.
|
|
106
|
+
class HmacSigner
|
|
107
|
+
PLACEHOLDERS = %w[timestamp body].freeze
|
|
108
|
+
DEFAULT_SIGNING_STRING = "{body}"
|
|
109
|
+
|
|
110
|
+
def initialize(secret:, header:, digest: :sha256, encoding: :hex, prefix: nil,
|
|
111
|
+
signing_string: DEFAULT_SIGNING_STRING, timestamp_header: nil)
|
|
112
|
+
validate_header_name!(:header, header)
|
|
113
|
+
unless timestamp_header.nil?
|
|
114
|
+
validate_header_name!(:timestamp_header, timestamp_header)
|
|
115
|
+
# The timestamp assignment in `call` lands SECOND and would overwrite the signature,
|
|
116
|
+
# shipping every delivery unverifiable — silently. HTTP header names are
|
|
117
|
+
# case-insensitive, so compare that way (Codex review).
|
|
118
|
+
if header.casecmp?(timestamp_header)
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"sign :hmac `header:` and `timestamp_header:` are the same header name " \
|
|
121
|
+
"(#{header.inspect}) — the timestamp would overwrite the signature"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Same reasoning as :standard_webhooks — `resolved_secret` calls with NO arguments, or
|
|
126
|
+
# with the PRO-3214 `Subscriber` for a 1-arity per-subscriber secret. A callable needing
|
|
127
|
+
# MORE than that boots fine and raises on every real signing attempt.
|
|
128
|
+
if secret.respond_to?(:call) && !(CallableArity.accepts?(secret, 0) || CallableArity.accepts?(secret, 1))
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"sign :hmac secret callable must accept zero or one arguments (resolved with no " \
|
|
131
|
+
"args, or the Subscriber, per signing attempt)"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Both are finite sets in Signature; an unvalidated typo boots fine and then raises
|
|
135
|
+
# inside EVERY delivery attempt — on the async path, after the job is enqueued, so it
|
|
136
|
+
# retries the same broken config (Codex review).
|
|
137
|
+
raise ArgumentError, "sign :hmac unsupported digest: #{digest.inspect}" unless Signature::DIGESTS.key?(digest)
|
|
138
|
+
raise ArgumentError, "sign :hmac unsupported encoding: #{encoding.inspect}" unless Signature::ENCODINGS.include?(encoding)
|
|
139
|
+
|
|
140
|
+
validate_template!(signing_string, timestamp_header)
|
|
141
|
+
|
|
142
|
+
# Copy every String we validated or emit. Validation runs ONCE, here; retaining the
|
|
143
|
+
# caller's mutable object lets an app change what ships afterwards —
|
|
144
|
+
# `header.replace("Content-Type")` walks straight past both the field-name grammar and
|
|
145
|
+
# the MANAGED_HEADERS collision rule, and Deliver then overwrites the signature (Codex
|
|
146
|
+
# review). Same validate-then-alias shape as Config's static `to:` array.
|
|
147
|
+
@secret = secret # NOT copied: may be a callable, and a String secret is re-read per call anyway
|
|
148
|
+
@header = dup_frozen(header)
|
|
149
|
+
@digest = digest
|
|
150
|
+
@encoding = encoding
|
|
151
|
+
@prefix = dup_frozen(prefix)
|
|
152
|
+
@signing_string = dup_frozen(signing_string)
|
|
153
|
+
@timestamp_header = dup_frozen(timestamp_header)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# `id:` is part of the signer contract but unused here — an id-bearing signature is what
|
|
157
|
+
# :standard_webhooks is for, and this preset emits no id header for a receiver to read one
|
|
158
|
+
# back from. Absorbed by `**` rather than named, so it isn't an unused argument.
|
|
159
|
+
def call(timestamp:, body:, subscriber: nil, **)
|
|
160
|
+
sig = Signature.compute(
|
|
161
|
+
secret: resolved_secret(subscriber),
|
|
162
|
+
payload: render(timestamp:, body:),
|
|
163
|
+
digest: @digest,
|
|
164
|
+
encoding: @encoding,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
headers = { @header => "#{@prefix}#{sig}" }
|
|
168
|
+
headers[@timestamp_header] = timestamp.to_s if @timestamp_header
|
|
169
|
+
headers
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def dup_frozen(value) = value.is_a?(String) ? value.dup.freeze : value
|
|
175
|
+
|
|
176
|
+
# A `to_s`-based blank check is not enough: `false.to_s` is "false" and `123.to_s` is
|
|
177
|
+
# "123", so both pass it and publish a signer that emits `{ false => "<sig>" }` for the
|
|
178
|
+
# transport to choke on mid-delivery — a boot-time declaration mistake turned into a
|
|
179
|
+
# repeatedly-retried delivery exception. For `timestamp_header:` a `false` is worse than
|
|
180
|
+
# useless: it satisfies the {timestamp}-needs-a-header rule below while `call`'s
|
|
181
|
+
# `if @timestamp_header` skips emitting it, leaving the receiver a timestamp-bound
|
|
182
|
+
# signature it cannot reconstruct (Codex review).
|
|
183
|
+
def validate_header_name!(option, value)
|
|
184
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
185
|
+
raise ArgumentError,
|
|
186
|
+
"sign :hmac `#{option}:` must be a non-empty String header name (got #{value.inspect})"
|
|
187
|
+
end
|
|
188
|
+
unless value.match?(HEADER_NAME)
|
|
189
|
+
raise ArgumentError,
|
|
190
|
+
"sign :hmac `#{option}:` must be a valid HTTP header name (got #{value.inspect}) — " \
|
|
191
|
+
"letters, digits and !#$%&'*+-.^_`|~ only, with no spaces, colons or newlines"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Everything set AFTER the signer runs: Deliver merges its own content-type/user-agent,
|
|
195
|
+
# and the transport regenerates content-length from the body at send time. Any of them
|
|
196
|
+
# emitted as the signature or timestamp header is silently replaced downstream.
|
|
197
|
+
# Resolved here rather than at load time — signer.rb is required before deliver.rb.
|
|
198
|
+
reserved = Deliver::MANAGED_HEADERS + Transport::RESERVED_HEADERS
|
|
199
|
+
return unless reserved.any? { |managed| managed.casecmp?(value) }
|
|
200
|
+
|
|
201
|
+
raise ArgumentError,
|
|
202
|
+
"sign :hmac `#{option}:` is #{value.inspect}, a header the delivery pipeline controls " \
|
|
203
|
+
"(#{reserved.join(', ')}) — it is set after signing and would replace this one"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def render(timestamp:, body:)
|
|
207
|
+
@signing_string.gsub(/\{(\w+)\}/) { Regexp.last_match(1) == "timestamp" ? timestamp.to_s : body }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A template (not a callable) so an unknown placeholder is caught HERE, at declaration
|
|
211
|
+
# time — impossible with a lambda. Anyone needing real logic has the custom `sign { … }`
|
|
212
|
+
# block already; a callable option would be a worse-ergonomics duplicate of it.
|
|
213
|
+
def validate_template!(template, timestamp_header)
|
|
214
|
+
raise ArgumentError, "sign :hmac `signing_string:` must be a String template (got #{template.class})" unless template.is_a?(String)
|
|
215
|
+
|
|
216
|
+
# Strip the KNOWN placeholders, then treat any brace left behind as an error. Scanning
|
|
217
|
+
# for `\{(\w+)\}` alone only ever saw well-formed braces, so `{time-stamp}` (hyphen) and
|
|
218
|
+
# `{timestamp` (unmatched) matched nothing, passed validation, and got signed as literal
|
|
219
|
+
# text — a signature the receiver cannot reconstruct, from the very option whose selling
|
|
220
|
+
# point is declaration-time validation (Codex review). A literal brace in a signing
|
|
221
|
+
# string is therefore not supported; it is far likelier to be a typo.
|
|
222
|
+
leftover = template.gsub(/\{(?:#{PLACEHOLDERS.join('|')})\}/, "")
|
|
223
|
+
if leftover.match?(/[{}]/)
|
|
224
|
+
bad = leftover.scan(/\{[^{}]*\}|[{}]/).uniq
|
|
225
|
+
raise ArgumentError,
|
|
226
|
+
"sign :hmac `signing_string:` has unknown or malformed placeholder(s) " \
|
|
227
|
+
"#{bad.map(&:inspect).join(', ')} (known: {timestamp}, {body})"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
found = template.scan(/\{(\w+)\}/).flatten.uniq
|
|
231
|
+
|
|
232
|
+
return unless found.include?("timestamp") && timestamp_header.nil?
|
|
233
|
+
|
|
234
|
+
raise ArgumentError,
|
|
235
|
+
"sign :hmac `signing_string:` references {timestamp} but no `timestamp_header:` is " \
|
|
236
|
+
"declared — the receiver would have no way to reconstruct the signed string"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# A blank or non-String secret would otherwise sign every delivery with an empty/garbage
|
|
240
|
+
# key, and the receiver's 401 is indistinguishable from any other misconfiguration. The
|
|
241
|
+
# message NEVER carries the secret's bytes: a callable secret is re-resolved per attempt,
|
|
242
|
+
# so this can raise on every delivery and would flow the live credential into whatever
|
|
243
|
+
# Axn.config.on_exception is wired to.
|
|
244
|
+
# Arity-aware, mirroring StandardWebhooksSigner's `resolve_secret`: a 1-arity secret
|
|
245
|
+
# callable gets the Subscriber (PRO-3214, a per-subscriber secret); a 0-arity one (or a
|
|
246
|
+
# plain value) resolves exactly as it did before subscriber-awareness existed.
|
|
247
|
+
def resolved_secret(subscriber)
|
|
248
|
+
secret = if @secret.respond_to?(:call)
|
|
249
|
+
# Prefer a ZERO-arg call whenever the callable can accept one: a PRE-EXISTING
|
|
250
|
+
# secret resolver with an unrelated optional arg (e.g. `->(app =
|
|
251
|
+
# Rails.application) { ... }`) must keep using ITS OWN default, not silently
|
|
252
|
+
# start receiving the Subscriber just because it COULD accept one arg. Only a
|
|
253
|
+
# callable that genuinely cannot be invoked with zero args gets the
|
|
254
|
+
# subscriber. Raw arity (`prefers_zero_args?`), not `#parameters`-based --
|
|
255
|
+
# a plain `proc { |subscriber| }` (no default) reports its param as `:opt`
|
|
256
|
+
# via `#parameters`, indistinguishable from a genuine default by that API,
|
|
257
|
+
# but its raw arity is still the correct positive `1` (Codex P1 finding: a
|
|
258
|
+
# `#parameters`-based check silently passed `nil` for exactly this shape).
|
|
259
|
+
CallableArity.prefers_zero_args?(@secret) ? @secret.call : @secret.call(subscriber)
|
|
260
|
+
else
|
|
261
|
+
@secret
|
|
262
|
+
end
|
|
263
|
+
return secret if secret.is_a?(String) && !secret.empty?
|
|
264
|
+
|
|
265
|
+
raise Axn::Webhooks::Error,
|
|
266
|
+
"sign :hmac secret must be a non-empty String (got #{secret.is_a?(String) ? 'an empty String' : secret.class})"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Standard Webhooks: secret is `whsec_<base64>`; sign `id.timestamp.body` (sha256/base64);
|
|
271
|
+
# emit `v1,<sig>` alongside the id/timestamp headers the inbound verifier reads.
|
|
272
|
+
class StandardWebhooksSigner
|
|
273
|
+
def initialize(secret:)
|
|
274
|
+
# A pure declaration mistake, decided once at boot from the callable's own shape (not
|
|
275
|
+
# from what it resolves to) — ArgumentError, matching Config's misconfiguration split.
|
|
276
|
+
# `resolve_secret` below calls `@secret.call` with NO arguments, or with the PRO-3214
|
|
277
|
+
# `Subscriber` for a 1-arity per-subscriber secret; a callable needing MORE than that
|
|
278
|
+
# would otherwise boot successfully and raise ArgumentError on every real signing attempt
|
|
279
|
+
# (Codex P2 finding, widened for the subscriber-aware case).
|
|
280
|
+
if secret.respond_to?(:call)
|
|
281
|
+
unless CallableArity.accepts?(secret, 0) || CallableArity.accepts?(secret, 1)
|
|
282
|
+
raise ArgumentError,
|
|
283
|
+
"sign :standard_webhooks secret callable must accept zero or one arguments " \
|
|
284
|
+
"(resolved with no args, or the Subscriber, per signing attempt)"
|
|
285
|
+
end
|
|
286
|
+
else
|
|
287
|
+
# A LITERAL secret is fully knowable now, so the whsec_ check that guards every signing
|
|
288
|
+
# attempt runs once here instead. Otherwise the natural mistake — pasting the raw key a
|
|
289
|
+
# vendor's dashboard shows you, without the `whsec_` prefix — declares cleanly and then
|
|
290
|
+
# raises inside EVERY delivery attempt, which is the worst place for it: an async
|
|
291
|
+
# adapter retries that as if it were a transient network failure. ArgumentError (not
|
|
292
|
+
# Axn::Webhooks::Error) to match the arity check above and Config's misconfiguration
|
|
293
|
+
# split: a declaration mistake, decided at boot.
|
|
294
|
+
#
|
|
295
|
+
# A CALLABLE secret is deliberately NOT resolved here — it may read a secret store or
|
|
296
|
+
# be per-subscriber, so its VALUE stays a per-attempt check (its arity is all that's
|
|
297
|
+
# knowable at boot). Documented in the README's "Boot-time validation" section.
|
|
298
|
+
raise ArgumentError, invalid_secret_message(secret) unless Verifiers::StandardWebhooks.secret_key(secret)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
@secret = secret
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# The raw HMAC key behind a Standard Webhooks secret, or nil if it isn't one: `whsec_` +
|
|
305
|
+
# a base64 body that decodes to something non-empty. Both the boot-time check above and
|
|
306
|
+
# the per-attempt `decoded_secret` below go through this, so validity and the decoded
|
|
307
|
+
# bytes can never disagree, and the decode happens exactly once per caller.
|
|
308
|
+
#
|
|
309
|
+
# An unprefixed or blank secret would otherwise decode "successfully" (both are valid
|
|
310
|
+
# base64) and sign every delivery with an empty or wrong key — silently, since the
|
|
311
|
+
# receiver's 401 is indistinguishable from any other misconfiguration (Codex P1 finding).
|
|
312
|
+
# The rescue is scoped to ONLY the decode: a callable secret's own resolver may raise its
|
|
313
|
+
# own ArgumentError for an unrelated reason (a secret-store wrapper rejecting a malformed
|
|
314
|
+
# response), and that diagnostic must reach Axn.config.on_exception intact rather than
|
|
315
|
+
# being rewritten as a generic invalid-secret message (Codex P2 finding) — which is why
|
|
316
|
+
# resolution happens in `decoded_secret`, outside this method.
|
|
317
|
+
def self.decode_secret(secret)
|
|
318
|
+
return nil unless secret.is_a?(String) && secret.start_with?("whsec_")
|
|
319
|
+
|
|
320
|
+
decoded = Verifiers::StandardWebhooks.decode_secret(secret)
|
|
321
|
+
decoded.empty? ? nil : decoded
|
|
322
|
+
rescue ArgumentError
|
|
323
|
+
nil
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def call(id:, timestamp:, body:, subscriber: nil)
|
|
327
|
+
sig = Signature.compute(
|
|
328
|
+
secret: decoded_secret(subscriber),
|
|
329
|
+
payload: "#{id}.#{timestamp}.#{body}",
|
|
330
|
+
digest: :sha256,
|
|
331
|
+
encoding: :base64,
|
|
332
|
+
)
|
|
333
|
+
{
|
|
334
|
+
"webhook-id" => id.to_s,
|
|
335
|
+
"webhook-timestamp" => timestamp.to_s,
|
|
336
|
+
"webhook-signature" => "v1,#{sig}",
|
|
337
|
+
}
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
private
|
|
341
|
+
|
|
342
|
+
# A callable secret (the norm for every other webhook secret in this gem — see
|
|
343
|
+
# Resolvers.resolve) resolves per call rather than being frozen at `sign` time; a plain
|
|
344
|
+
# value is used as-is. Arity-aware (PRO-3214): a 1-arity callable gets the Subscriber (a
|
|
345
|
+
# per-subscriber secret); a 0-arity one resolves exactly as it did before subscriber-
|
|
346
|
+
# awareness existed.
|
|
347
|
+
def resolve_secret(subscriber)
|
|
348
|
+
return @secret unless @secret.respond_to?(:call)
|
|
349
|
+
|
|
350
|
+
# See the identical precedence rationale (and the Proc/#parameters quirk it works
|
|
351
|
+
# around) on HmacSigner#resolved_secret above (Codex P1 finding).
|
|
352
|
+
CallableArity.prefers_zero_args?(@secret) ? @secret.call : @secret.call(subscriber)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# `resolve_secret` runs OUTSIDE the guard on purpose — a secret store's own ArgumentError
|
|
356
|
+
# must reach the exception reporter intact, not be rewritten as an invalid-secret message.
|
|
357
|
+
def decoded_secret(subscriber)
|
|
358
|
+
secret = resolve_secret(subscriber)
|
|
359
|
+
|
|
360
|
+
Verifiers::StandardWebhooks.secret_key(secret) || raise(invalid_secret_error(secret))
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def invalid_secret_error(secret)
|
|
364
|
+
Axn::Webhooks::Error.new(invalid_secret_message(secret))
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Shared with inbound `verify :standard_webhooks` so both halves reject the same values
|
|
368
|
+
# and describe them the same way — never interpolating the secret's own bytes.
|
|
369
|
+
def invalid_secret_message(secret)
|
|
370
|
+
Verifiers::StandardWebhooks.invalid_secret_message("sign :standard_webhooks", secret)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|