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,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Verifiers
|
|
6
|
+
# Parametric HMAC strategy. Resolves each option against the request at verify time
|
|
7
|
+
# and delegates to the constant-time Signature primitive.
|
|
8
|
+
register(:hmac) do |secret:, signature:, signing_string: :raw_body, digest: :sha256,
|
|
9
|
+
encoding: :hex, prefix: nil, replay: nil|
|
|
10
|
+
if replay
|
|
11
|
+
# Compare stringified keys so a HashWithIndifferentAccess (string keys) isn't
|
|
12
|
+
# misclassified as entirely unsupported.
|
|
13
|
+
allowed = %w[timestamp within unit]
|
|
14
|
+
unknown = replay.keys.reject { |key| allowed.include?(key.to_s) }
|
|
15
|
+
raise ArgumentError, "unsupported replay: key(s): #{unknown.map(&:inspect).join(', ')}" if unknown.any?
|
|
16
|
+
|
|
17
|
+
# Declaring `replay:` at all is an explicit request FOR replay protection, so a blank or
|
|
18
|
+
# non-positive `within:` is unambiguously a mistake — and one that silently disabled the
|
|
19
|
+
# guard rather than failing (security audit). Caught at boot, like every other declaration
|
|
20
|
+
# error, rather than on the first replayed request nobody notices.
|
|
21
|
+
within = replay[:within] || replay["within"]
|
|
22
|
+
unless within.is_a?(Numeric) && within.positive?
|
|
23
|
+
raise ArgumentError, "verify :hmac replay: `within:` must be a positive number of seconds (got #{within.inspect})"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A literal secret is knowable now; a callable/Resolver is checked per request below.
|
|
28
|
+
literal_secret = !Resolvers.deferred?(secret)
|
|
29
|
+
Verifiers.require_secret!("verify :hmac", secret, error: ArgumentError) if literal_secret
|
|
30
|
+
|
|
31
|
+
lambda do |request|
|
|
32
|
+
timestamp = replay && Resolvers.resolve(replay.fetch(:timestamp), request)
|
|
33
|
+
# hmac_check (not hmac): Verify reads the returned Signature::Check to report WHY the
|
|
34
|
+
# request was rejected, so a replay-window miss is separable from an HMAC mismatch.
|
|
35
|
+
Signature.hmac_check(
|
|
36
|
+
# Resolve THEN guard, on every request: a resolver can miss (an unset env var, an
|
|
37
|
+
# absent header, a tenant lookup the ATTACKER names) long after boot, and "" is a legal
|
|
38
|
+
# HMAC key rather than a failure.
|
|
39
|
+
secret: Verifiers.require_secret!("verify :hmac", Resolvers.resolve(secret, request)),
|
|
40
|
+
payload: Resolvers.resolve(signing_string, request),
|
|
41
|
+
signature: Resolvers.resolve(signature, request),
|
|
42
|
+
digest:,
|
|
43
|
+
encoding:,
|
|
44
|
+
prefix:,
|
|
45
|
+
timestamp:,
|
|
46
|
+
# Omitted (not nil) when no replay is declared: Signature distinguishes "no replay
|
|
47
|
+
# check requested" from "a blank tolerance arrived from somewhere", and only the
|
|
48
|
+
# former is legitimate.
|
|
49
|
+
tolerance: replay ? replay.fetch(:within) { replay.fetch("within") } : Signature::NO_TOLERANCE,
|
|
50
|
+
# Default only when `unit:` is absent — an explicit `unit: nil`/`false` (e.g. an
|
|
51
|
+
# unset env var) must still hit Signature's ArgumentError, not silently become :auto.
|
|
52
|
+
unit: replay&.key?(:unit) ? replay[:unit] : Signature::AUTO,
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Axn
|
|
6
|
+
module Webhooks
|
|
7
|
+
module Verifiers
|
|
8
|
+
# Standard Webhooks (Svix) scheme. Secret is `whsec_<base64>`; the signed string is
|
|
9
|
+
# `id.timestamp.body`; the signature header holds space-separated `v1,<base64sig>`
|
|
10
|
+
# candidates; a ±tolerance replay window applies.
|
|
11
|
+
module StandardWebhooks
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# NOT `secret.to_s` (Codex round-4 finding): coercing here is what turned a nil secret —
|
|
15
|
+
# an unset ENV var, or a `header(...)` resolver on an absent header — into "", which is
|
|
16
|
+
# valid Base64 and decodes to an EMPTY HMAC key. That is an authentication bypass, since
|
|
17
|
+
# anyone who knows the credential is missing can sign with the empty key. Callers must hand
|
|
18
|
+
# this a String; `secret_key` guards the type, and `require_secret_key!` is the safe entry
|
|
19
|
+
# point for anything resolved at request time.
|
|
20
|
+
def decode_secret(secret)
|
|
21
|
+
raise ArgumentError, "secret must be a String (got #{secret.class})" unless secret.is_a?(String)
|
|
22
|
+
|
|
23
|
+
Base64.strict_decode64(secret.delete_prefix("whsec_"))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The raw key for a secret resolved at REQUEST time, raising if it isn't usable. Loud on
|
|
27
|
+
# purpose: a secret that has gone missing is a misconfiguration worth paging on, and must
|
|
28
|
+
# never degrade into a quiet :signature_mismatch that reads like a rotated key.
|
|
29
|
+
def require_secret_key!(secret)
|
|
30
|
+
secret_key(secret) || raise(Axn::Webhooks::Error, invalid_secret_message("verify :standard_webhooks", secret))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The raw HMAC key behind a `whsec_<base64>` secret, or nil if the value isn't one.
|
|
34
|
+
# The single source of truth for "is this a usable Standard Webhooks secret", shared by
|
|
35
|
+
# inbound's declaration-time check and outbound's (Outbound::Signer), so the two can't drift.
|
|
36
|
+
#
|
|
37
|
+
# The `whsec_` prefix check is what carries this: an unprefixed secret is very often still
|
|
38
|
+
# VALID Base64 — a 32-char hex secret is, and that's a common shape — so it would decode
|
|
39
|
+
# silently to the wrong key rather than raising. The rescue is scoped to the decode alone;
|
|
40
|
+
# a caller that RESOLVES a secret (from a callable or a secret store) must do so outside
|
|
41
|
+
# this method, or its own ArgumentError would be swallowed and rewritten.
|
|
42
|
+
def secret_key(secret)
|
|
43
|
+
return nil unless secret.is_a?(String) && secret.start_with?("whsec_")
|
|
44
|
+
|
|
45
|
+
key = decode_secret(secret)
|
|
46
|
+
key.empty? ? nil : key
|
|
47
|
+
rescue ArgumentError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Describes a rejected secret's SHAPE for an error message, never its bytes: this can be
|
|
52
|
+
# raised per delivery attempt on the outbound side, and would otherwise flow the live
|
|
53
|
+
# signing credential into whatever Axn.config.on_exception is wired to.
|
|
54
|
+
def describe_secret(secret)
|
|
55
|
+
return secret.class.name unless secret.is_a?(String)
|
|
56
|
+
return "a #{secret.length}-char String not prefixed with whsec_" unless secret.start_with?("whsec_")
|
|
57
|
+
|
|
58
|
+
"a whsec_-prefixed String that failed to decode"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def invalid_secret_message(declaration, secret)
|
|
62
|
+
"#{declaration} secret must be a whsec_<base64> value (got #{describe_secret(secret)})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Keep only `v1,<sig>` candidates, stripped to the bare base64 signature.
|
|
66
|
+
# Done here (not via Signature's generic splitter) because that splitter treats
|
|
67
|
+
# the comma as a separator and would break `v1,<sig>` into two tokens.
|
|
68
|
+
def extract_v1(header)
|
|
69
|
+
header.to_s.split(/\s+/).select { |t| t.start_with?("v1,") }.map { |t| t.delete_prefix("v1,") }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
register(:standard_webhooks) do |secret:, tolerance: 300,
|
|
74
|
+
id: Resolvers.header("webhook-id"),
|
|
75
|
+
timestamp: Resolvers.header("webhook-timestamp"),
|
|
76
|
+
signature: Resolvers.header("webhook-signature")|
|
|
77
|
+
# A LITERAL secret is fully knowable now, so the whsec_ format is checked once here (this
|
|
78
|
+
# block runs at `inbound` declaration) instead of failing every request forever. Symmetric
|
|
79
|
+
# with outbound `sign :standard_webhooks`, and ArgumentError for the same reason: a
|
|
80
|
+
# declaration mistake, not a runtime condition.
|
|
81
|
+
#
|
|
82
|
+
# Worth the eager check because BOTH request-time failure modes are near-undiagnosable: a
|
|
83
|
+
# non-Base64 raw secret raises (reported as a verifier crash, no `reason` on the result),
|
|
84
|
+
# and a raw secret that IS valid Base64 decodes silently to the wrong key — a quiet
|
|
85
|
+
# :signature_mismatch, nothing reported anywhere, indistinguishable from a rotated key.
|
|
86
|
+
#
|
|
87
|
+
# Exempt CALLABLES (a lambda, or a Resolver like `header("X-Secret")`) — deliberately not
|
|
88
|
+
# resolved here, since they may read a secret store or an env var set after boot, so their
|
|
89
|
+
# value stays a per-request concern. Everything ELSE is validated now.
|
|
90
|
+
#
|
|
91
|
+
# Keyed on respond_to?(:call), NOT on is_a?(String) (Codex round-3 finding): a `nil` secret
|
|
92
|
+
# — an unset ENV var being the obvious way to get one — is neither a String nor a callable,
|
|
93
|
+
# so a String-keyed check waved it through to request time, where `decode_secret` coerced it
|
|
94
|
+
# with #to_s and Base64-decoded "" into an EMPTY HMAC key. That is an authentication bypass,
|
|
95
|
+
# not a mismatch: anyone who knows the secret is unset can compute a signature with the empty
|
|
96
|
+
# key and be verified. Same fail-closed-on-blank stance verify :basic_auth already takes.
|
|
97
|
+
# An explicitly blank tolerance would silently disable the replay window; the 300s default
|
|
98
|
+
# applies only when the caller omits it entirely (security audit).
|
|
99
|
+
unless tolerance.is_a?(Numeric) && tolerance.positive?
|
|
100
|
+
raise ArgumentError,
|
|
101
|
+
"verify :standard_webhooks tolerance: must be a positive number of seconds (got #{tolerance.inspect})"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
unless Resolvers.deferred?(secret) || StandardWebhooks.secret_key(secret)
|
|
105
|
+
raise ArgumentError, StandardWebhooks.invalid_secret_message("verify :standard_webhooks", secret)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
lambda do |request|
|
|
109
|
+
ts = Resolvers.resolve(timestamp, request)
|
|
110
|
+
payload = "#{Resolvers.resolve(id, request)}.#{ts}.#{request.raw_body}"
|
|
111
|
+
candidates = StandardWebhooks.extract_v1(Resolvers.resolve(signature, request))
|
|
112
|
+
|
|
113
|
+
# hmac_check (not hmac): returns a Signature::Check so Verify can name the cause.
|
|
114
|
+
Signature.hmac_check(
|
|
115
|
+
# Resolve THEN validate. A declaration-time check can't cover this: a callable or
|
|
116
|
+
# Resolver is resolved fresh per request and can go missing at any point after boot.
|
|
117
|
+
secret: StandardWebhooks.require_secret_key!(Resolvers.resolve(secret, request)),
|
|
118
|
+
payload:,
|
|
119
|
+
signature: candidates.join(" "),
|
|
120
|
+
digest: :sha256,
|
|
121
|
+
encoding: :base64,
|
|
122
|
+
timestamp: ts,
|
|
123
|
+
tolerance:,
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
# Builds a verifier callable (->(request){ Boolean }) from a `verify` declaration.
|
|
6
|
+
# A custom block is used verbatim; a strategy symbol is looked up in STRATEGIES
|
|
7
|
+
# (populated by verifiers/*.rb).
|
|
8
|
+
module Verifiers
|
|
9
|
+
STRATEGIES = {} # rubocop:disable Style/MutableConstant
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def register(name, &builder) = STRATEGIES[name.to_sym] = builder
|
|
14
|
+
|
|
15
|
+
# THE shared secret guard. Every strategy in this gem routes its secret/credential through
|
|
16
|
+
# here, in both directions, so a new strategy cannot quietly reintroduce the bug this exists
|
|
17
|
+
# for — which has now recurred four times, each fix having been applied only where it was
|
|
18
|
+
# noticed (literal whsec_, resolved whsec_, `verify :hmac`, `verify :basic_auth`).
|
|
19
|
+
#
|
|
20
|
+
# The bug: a blank or absent secret is not a failure, it is a WEAK KEY. `""` is a perfectly
|
|
21
|
+
# legal HMAC key, so an empty secret makes the expected signature a value any stranger can
|
|
22
|
+
# compute — an authentication bypass, not a mismatch. `nil` happens to fail closed only by
|
|
23
|
+
# accident (OpenSSL raises TypeError on it), which is precisely why checking nil alone gives
|
|
24
|
+
# false confidence.
|
|
25
|
+
#
|
|
26
|
+
# Raises rather than returning false: a 401 meaning "we are misconfigured" is indistinguishable
|
|
27
|
+
# from one meaning "you are not the vendor", and would otherwise present as an unexplained
|
|
28
|
+
# outage. Names the value's TYPE or emptiness only — never its bytes, since this can fire on
|
|
29
|
+
# every request and would otherwise flow the live credential into logs and error trackers.
|
|
30
|
+
# `error:` follows this gem's misconfiguration split: ArgumentError for a DECLARATION mistake
|
|
31
|
+
# caught at boot, Axn::Webhooks::Error for a value that only goes bad at request time.
|
|
32
|
+
def require_secret!(declaration, value, label: "secret", error: Axn::Webhooks::Error)
|
|
33
|
+
return value if value.is_a?(String) && !value.empty?
|
|
34
|
+
|
|
35
|
+
raise error,
|
|
36
|
+
"#{declaration} #{label} must be a non-empty String " \
|
|
37
|
+
"(got #{value.is_a?(String) ? 'an empty String' : value.class})"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build(strategy:, opts:, block:)
|
|
41
|
+
return block if block
|
|
42
|
+
|
|
43
|
+
builder = STRATEGIES.fetch(strategy&.to_sym) do
|
|
44
|
+
raise Axn::Webhooks::Error, "unknown verify strategy #{strategy.inspect}"
|
|
45
|
+
end
|
|
46
|
+
builder.call(**opts)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
# The verify stage, as an Axn. A signature mismatch is a quiet failure (`fail!` →
|
|
6
|
+
# 401 later, no on_exception page); a verifier that raises is a loud exception
|
|
7
|
+
# (reported to Axn.config.on_exception). The first two rows of the staged-outcome model.
|
|
8
|
+
class Verify
|
|
9
|
+
include Axn
|
|
10
|
+
include Axn::Webhooks::VendorFacet
|
|
11
|
+
|
|
12
|
+
# Why the request was rejected, keyed by Signature::REASONS. The HTTP response is a bare
|
|
13
|
+
# 401 in every case — this exists so the two causes are separable in logs and metrics
|
|
14
|
+
# (PRO-3141: a replay-window miss and an HMAC mismatch were byte-identical in the logs,
|
|
15
|
+
# and the replay case reported "signature mismatch" for a request whose signature was valid).
|
|
16
|
+
MESSAGES = {
|
|
17
|
+
replay_window: ->(check) { "replay window exceeded (timestamp skew #{check.skew}s)" },
|
|
18
|
+
replay_timestamp_invalid: ->(_check) { "replay timestamp missing or unparseable" },
|
|
19
|
+
signature_missing: ->(_check) { "signature missing" },
|
|
20
|
+
signature_mismatch: ->(_check) { "signature mismatch" },
|
|
21
|
+
# A genuine anomaly, and worth alerting on: the client presented an `Authorization` header,
|
|
22
|
+
# so it meant to authenticate, but not a Basic one. The bare handshake leg — which used to
|
|
23
|
+
# land here, once per *successful* webhook, making this the highest-volume value of the
|
|
24
|
+
# dimension — is answered with the challenge before Verify runs at all now (PRO-3148), so
|
|
25
|
+
# the message names what is actually left rather than the leg it no longer reports.
|
|
26
|
+
credentials_missing: lambda { |_check|
|
|
27
|
+
"no Basic credentials offered — a non-Basic Authorization scheme (the bare handshake leg " \
|
|
28
|
+
"is challenged before Verify)"
|
|
29
|
+
},
|
|
30
|
+
credentials_mismatch: ->(_check) { "Basic credentials rejected" },
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
expects :request, type: Axn::Webhooks::Request, sensitive: true
|
|
34
|
+
# A verifier closes over or holds the vendor's secret — that's its whole job — so it must
|
|
35
|
+
# never be rendered into the per-call log line. The built-in strategies redact themselves
|
|
36
|
+
# too (see Verifiers::BasicAuth#inspect), but this is the boundary that has to hold: a
|
|
37
|
+
# custom `verify` block or a future strategy can't be relied on to have thought about it.
|
|
38
|
+
expects :verifier, sensitive: true
|
|
39
|
+
exposes :reason, allow_blank: true, default: nil
|
|
40
|
+
exposes :skew, allow_blank: true, default: nil
|
|
41
|
+
exposes :suggested_unit, allow_blank: true, default: nil
|
|
42
|
+
# Deliberately mechanism-neutral: this prefixes every reason's message, and `verify :basic_auth`
|
|
43
|
+
# rejects requests on endpoints where no signature exists — "signature verification failed:
|
|
44
|
+
# Basic credentials rejected" would reintroduce, in the very first words an operator reads,
|
|
45
|
+
# the misdirection `reason` was added to end. The signature cases lose nothing, since their
|
|
46
|
+
# own half of the message still names the signature (see MESSAGES).
|
|
47
|
+
error "Webhook verification failed"
|
|
48
|
+
|
|
49
|
+
# A bounded enum (4 values), so unlike :vendor it's stamped unconditionally rather than
|
|
50
|
+
# gated behind Axn::Webhooks.config.vendor_facet — separating the causes is the reason
|
|
51
|
+
# this facet exists, and a default install needs it as much as a configured one.
|
|
52
|
+
# `from: :result` because the reason isn't known until the body has run.
|
|
53
|
+
dimension :reason, -> { @reason }, from: :result
|
|
54
|
+
|
|
55
|
+
# Also bounded (3 scales + absent), and set only when a pinned `unit:` — not a real replay —
|
|
56
|
+
# is what pushed the timestamp out of the window. Its presence alone splits the misconfigured
|
|
57
|
+
# half of :replay_window from the genuine half; its value names the fix (PRO-3142).
|
|
58
|
+
dimension :suggested_unit, -> { @suggested_unit }, from: :result
|
|
59
|
+
|
|
60
|
+
def call
|
|
61
|
+
check = verifier.call(request)
|
|
62
|
+
return if verified?(check)
|
|
63
|
+
|
|
64
|
+
# Set before fail! so the result-phase dimension resolvers can read them.
|
|
65
|
+
rejection = check.is_a?(Signature::Check) ? check : Signature::MISMATCH
|
|
66
|
+
@reason = rejection.reason
|
|
67
|
+
@skew = rejection.skew
|
|
68
|
+
@suggested_unit = rejection.suggested_unit
|
|
69
|
+
fail!(message_for(rejection), reason: @reason, skew: @skew, suggested_unit: @suggested_unit)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# The reason's own message, plus the suggested unit when one applies — "would fit as
|
|
75
|
+
# unit: :ms" alongside a 56-year skew names a misconfiguration outright, where the skew
|
|
76
|
+
# alone still reads as a possible replay. A timestamp is not a secret and the HTTP
|
|
77
|
+
# response is a bare 401 either way, so this discloses nothing to the sender.
|
|
78
|
+
def message_for(rejection)
|
|
79
|
+
message = MESSAGES.fetch(rejection.reason).call(rejection)
|
|
80
|
+
return message unless rejection.suggested_unit
|
|
81
|
+
|
|
82
|
+
"#{message} — would fit as unit: #{rejection.suggested_unit.inspect}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Anything that reports its OWN verdict via #ok? is asked; anything else (a custom verifier
|
|
86
|
+
# block, per the documented `->(request) { Boolean }` contract) is read for truthiness.
|
|
87
|
+
#
|
|
88
|
+
# Duck-typed on #ok? rather than `is_a?(Signature::Check)` because the truthiness fallback is
|
|
89
|
+
# a silent authentication-disabled bug for any verdict object: a REJECTING one is still a
|
|
90
|
+
# truthy Ruby object, so it verified and dispatched every rejected request while recording no
|
|
91
|
+
# verify failure anywhere. `Axn::Result` is the one that actually bites — in an axn-consuming
|
|
92
|
+
# app, returning `MyCheck.call(request:)` from a `verify` block is the obvious thing to write.
|
|
93
|
+
#
|
|
94
|
+
# Order matters: #ok? is asked FIRST, since every such object is truthy regardless of verdict.
|
|
95
|
+
# A truthy object with no #ok? still means verified — plenty of custom blocks end in a lookup
|
|
96
|
+
# returning a record rather than a boolean — and nil/false still mean rejected, since neither
|
|
97
|
+
# responds to #ok?.
|
|
98
|
+
#
|
|
99
|
+
# NOTE this reads the object's own notion of "ok", which for an Axn::Result means the action
|
|
100
|
+
# SUCCEEDED, not necessarily that the signature was valid. An action that returns ok while
|
|
101
|
+
# carrying its verdict in an exposure is still mis-read; see the README's custom-verify
|
|
102
|
+
# section. Fixing the always-verifies case does not make every Result shape safe.
|
|
103
|
+
def verified?(check) = check.respond_to?(:ok?) ? check.ok? : !!check
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/axn/webhooks.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "axn"
|
|
4
|
+
require "active_support/deprecation"
|
|
5
|
+
|
|
6
|
+
require_relative "webhooks/errors"
|
|
7
|
+
require_relative "webhooks/handler"
|
|
8
|
+
require_relative "webhooks/version"
|
|
9
|
+
require_relative "webhooks/header_value"
|
|
10
|
+
require_relative "webhooks/request"
|
|
11
|
+
require_relative "webhooks/response"
|
|
12
|
+
require_relative "webhooks/signature"
|
|
13
|
+
require_relative "webhooks/resolvers"
|
|
14
|
+
require_relative "webhooks/vendor_facet"
|
|
15
|
+
require_relative "webhooks/verify"
|
|
16
|
+
require_relative "webhooks/verifiers"
|
|
17
|
+
require_relative "webhooks/verifiers/basic_auth"
|
|
18
|
+
require_relative "webhooks/verifiers/hmac"
|
|
19
|
+
require_relative "webhooks/verifiers/standard_webhooks"
|
|
20
|
+
require_relative "webhooks/inbound"
|
|
21
|
+
require_relative "webhooks/inbound/challenge"
|
|
22
|
+
require_relative "webhooks/inbound/parsers"
|
|
23
|
+
require_relative "webhooks/inbound/build_request"
|
|
24
|
+
require_relative "webhooks/inbound/challenge_required"
|
|
25
|
+
require_relative "webhooks/inbound/respond_context"
|
|
26
|
+
require_relative "webhooks/respond"
|
|
27
|
+
require_relative "webhooks/static_respond"
|
|
28
|
+
require_relative "webhooks/dispatch"
|
|
29
|
+
require_relative "webhooks/outbound"
|
|
30
|
+
|
|
31
|
+
module Axn
|
|
32
|
+
module Webhooks
|
|
33
|
+
extend Axn::Configurable
|
|
34
|
+
|
|
35
|
+
# Per-gem config namespace (Axn::Configurable, PRO-2880), so settings declared here don't
|
|
36
|
+
# collide with another adapter configured on the same action.
|
|
37
|
+
config_namespace :webhooks
|
|
38
|
+
|
|
39
|
+
# Per-vendor observability facet (spec Decision 7 / PRO-2818). Off by default; a consuming app
|
|
40
|
+
# (Teamshares: :dimension) opts in. See Axn::Webhooks::VendorFacet for the runtime mechanism.
|
|
41
|
+
setting :vendor_facet, default: false, one_of: [false, :dimension, :tag]
|
|
42
|
+
|
|
43
|
+
# The HTTP status an inbound endpoint returns when a VERIFIED request's body doesn't parse
|
|
44
|
+
# (PRO-3143). 200 by default, because retrying can never fix a malformed body and 2xx is the only
|
|
45
|
+
# answer every vendor reads as "stop redelivering": Lob (5 days, then it disables the endpoint),
|
|
46
|
+
# Stripe, Slack and Shopify all retry non-2xx, and the last two also disable an endpoint after
|
|
47
|
+
# sustained failures — so a semantically-tidy 400 buys a retry loop from most senders. Set 400 for
|
|
48
|
+
# a vendor that does treat 4xx as terminal (honest status codes in their delivery dashboard), or
|
|
49
|
+
# 500 to restore the pre-PRO-3143 behavior. Per-endpoint override: `dispatch unparseable_status:`.
|
|
50
|
+
setting :unparseable_status,
|
|
51
|
+
default: 200,
|
|
52
|
+
validate: ->(value) { Response.valid_status?(value) || "must be an Integer HTTP status between 200 and 599" }
|
|
53
|
+
|
|
54
|
+
# A dedicated deprecator instance, so a consuming Rails app can register it
|
|
55
|
+
# (Rails.application.deprecators[:webhooks] = Axn::Webhooks.deprecator) and govern
|
|
56
|
+
# its behavior (silence in test, raise in CI, etc.).
|
|
57
|
+
def self.deprecator
|
|
58
|
+
@deprecator ||= ActiveSupport::Deprecation.new("1.0", "axn-webhooks")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/axn-webhooks.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: axn-webhooks
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kali Donovan
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: axn
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0.pre.alpha.5
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: 0.2.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.0.pre.alpha.5
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: 0.2.0
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rack
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.0'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '4'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '3.0'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '4'
|
|
52
|
+
description: 'Webhook handling for axn, both directions: verify/dispatch/acknowledge
|
|
53
|
+
inbound, and emit signed, self-retrying deliveries outbound. Rails-optional.'
|
|
54
|
+
email:
|
|
55
|
+
- kali@teamshares.com
|
|
56
|
+
executables: []
|
|
57
|
+
extensions: []
|
|
58
|
+
extra_rdoc_files: []
|
|
59
|
+
files:
|
|
60
|
+
- CHANGELOG.md
|
|
61
|
+
- DESIGN-NOTES.md
|
|
62
|
+
- LICENSE.txt
|
|
63
|
+
- README.md
|
|
64
|
+
- lib/axn-webhooks.rb
|
|
65
|
+
- lib/axn/webhooks.rb
|
|
66
|
+
- lib/axn/webhooks/dispatch.rb
|
|
67
|
+
- lib/axn/webhooks/errors.rb
|
|
68
|
+
- lib/axn/webhooks/handler.rb
|
|
69
|
+
- lib/axn/webhooks/header_value.rb
|
|
70
|
+
- lib/axn/webhooks/inbound.rb
|
|
71
|
+
- lib/axn/webhooks/inbound/build_request.rb
|
|
72
|
+
- lib/axn/webhooks/inbound/challenge.rb
|
|
73
|
+
- lib/axn/webhooks/inbound/challenge_required.rb
|
|
74
|
+
- lib/axn/webhooks/inbound/dsl.rb
|
|
75
|
+
- lib/axn/webhooks/inbound/endpoint.rb
|
|
76
|
+
- lib/axn/webhooks/inbound/parsers.rb
|
|
77
|
+
- lib/axn/webhooks/inbound/respond_context.rb
|
|
78
|
+
- lib/axn/webhooks/inbound/router.rb
|
|
79
|
+
- lib/axn/webhooks/outbound.rb
|
|
80
|
+
- lib/axn/webhooks/outbound/callable_arity.rb
|
|
81
|
+
- lib/axn/webhooks/outbound/config.rb
|
|
82
|
+
- lib/axn/webhooks/outbound/deliver.rb
|
|
83
|
+
- lib/axn/webhooks/outbound/dsl.rb
|
|
84
|
+
- lib/axn/webhooks/outbound/emit.rb
|
|
85
|
+
- lib/axn/webhooks/outbound/envelope.rb
|
|
86
|
+
- lib/axn/webhooks/outbound/signer.rb
|
|
87
|
+
- lib/axn/webhooks/outbound/subscriber.rb
|
|
88
|
+
- lib/axn/webhooks/outbound/target_policy.rb
|
|
89
|
+
- lib/axn/webhooks/outbound/transport.rb
|
|
90
|
+
- lib/axn/webhooks/request.rb
|
|
91
|
+
- lib/axn/webhooks/resolvers.rb
|
|
92
|
+
- lib/axn/webhooks/respond.rb
|
|
93
|
+
- lib/axn/webhooks/response.rb
|
|
94
|
+
- lib/axn/webhooks/signature.rb
|
|
95
|
+
- lib/axn/webhooks/static_respond.rb
|
|
96
|
+
- lib/axn/webhooks/vendor_facet.rb
|
|
97
|
+
- lib/axn/webhooks/verifiers.rb
|
|
98
|
+
- lib/axn/webhooks/verifiers/basic_auth.rb
|
|
99
|
+
- lib/axn/webhooks/verifiers/hmac.rb
|
|
100
|
+
- lib/axn/webhooks/verifiers/standard_webhooks.rb
|
|
101
|
+
- lib/axn/webhooks/verify.rb
|
|
102
|
+
- lib/axn/webhooks/version.rb
|
|
103
|
+
homepage: https://github.com/teamshares/axn-webhooks
|
|
104
|
+
licenses:
|
|
105
|
+
- MIT
|
|
106
|
+
metadata:
|
|
107
|
+
homepage_uri: https://github.com/teamshares/axn-webhooks
|
|
108
|
+
source_code_uri: https://github.com/teamshares/axn-webhooks
|
|
109
|
+
changelog_uri: https://github.com/teamshares/axn-webhooks/blob/main/CHANGELOG.md
|
|
110
|
+
rubygems_mfa_required: 'true'
|
|
111
|
+
rdoc_options: []
|
|
112
|
+
require_paths:
|
|
113
|
+
- lib
|
|
114
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
115
|
+
requirements:
|
|
116
|
+
- - ">="
|
|
117
|
+
- !ruby/object:Gem::Version
|
|
118
|
+
version: 3.2.1
|
|
119
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0'
|
|
124
|
+
requirements: []
|
|
125
|
+
rubygems_version: 3.6.8
|
|
126
|
+
specification_version: 4
|
|
127
|
+
summary: "Axn + webhooks = \U0001F525"
|
|
128
|
+
test_files: []
|