Class: Axn::Webhooks::Outbound::Signer::StandardWebhooksSigner

Inherits:
Object
  • Object
show all
Defined in:
lib/axn/webhooks/outbound/signer.rb

Overview

Standard Webhooks: secret is whsec_<base64>; sign id.timestamp.body (sha256/base64); emit v1,<sig> alongside the id/timestamp headers the inbound verifier reads.

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(secret:) ⇒ StandardWebhooksSigner

Returns a new instance of StandardWebhooksSigner.



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/axn/webhooks/outbound/signer.rb', line 273

def initialize(secret:)
  # A pure declaration mistake, decided once at boot from the callable's own shape (not
  # from what it resolves to) — ArgumentError, matching Config's misconfiguration split.
  # `resolve_secret` below calls `@secret.call` with NO arguments, or with the PRO-3214
  # `Subscriber` for a 1-arity per-subscriber secret; a callable needing MORE than that
  # would otherwise boot successfully and raise ArgumentError on every real signing attempt
  # (Codex P2 finding, widened for the subscriber-aware case).
  if secret.respond_to?(:call)
    unless CallableArity.accepts?(secret, 0) || CallableArity.accepts?(secret, 1)
      raise ArgumentError,
            "sign :standard_webhooks secret callable must accept zero or one arguments " \
            "(resolved with no args, or the Subscriber, per signing attempt)"
    end
  else
    # A LITERAL secret is fully knowable now, so the whsec_ check that guards every signing
    # attempt runs once here instead. Otherwise the natural mistake — pasting the raw key a
    # vendor's dashboard shows you, without the `whsec_` prefix — declares cleanly and then
    # raises inside EVERY delivery attempt, which is the worst place for it: an async
    # adapter retries that as if it were a transient network failure. ArgumentError (not
    # Axn::Webhooks::Error) to match the arity check above and Config's misconfiguration
    # split: a declaration mistake, decided at boot.
    #
    # A CALLABLE secret is deliberately NOT resolved here — it may read a secret store or
    # be per-subscriber, so its VALUE stays a per-attempt check (its arity is all that's
    # knowable at boot). Documented in the README's "Boot-time validation" section.
    raise ArgumentError, invalid_secret_message(secret) unless Verifiers::StandardWebhooks.secret_key(secret)
  end

  @secret = secret
end

Class Method Details

.decode_secret(secret) ⇒ Object

The raw HMAC key behind a Standard Webhooks secret, or nil if it isn't one: whsec_ + a base64 body that decodes to something non-empty. Both the boot-time check above and the per-attempt decoded_secret below go through this, so validity and the decoded bytes can never disagree, and the decode happens exactly once per caller.

An unprefixed or blank secret would otherwise decode "successfully" (both are valid base64) and sign every delivery with an empty or wrong key — silently, since the receiver's 401 is indistinguishable from any other misconfiguration (Codex P1 finding). The rescue is scoped to ONLY the decode: a callable secret's own resolver may raise its own ArgumentError for an unrelated reason (a secret-store wrapper rejecting a malformed response), and that diagnostic must reach Axn.config.on_exception intact rather than being rewritten as a generic invalid-secret message (Codex P2 finding) — which is why resolution happens in decoded_secret, outside this method.



317
318
319
320
321
322
323
324
# File 'lib/axn/webhooks/outbound/signer.rb', line 317

def self.decode_secret(secret)
  return nil unless secret.is_a?(String) && secret.start_with?("whsec_")

  decoded = Verifiers::StandardWebhooks.decode_secret(secret)
  decoded.empty? ? nil : decoded
rescue ArgumentError
  nil
end

Instance Method Details

#call(id:, timestamp:, body:, subscriber: nil) ⇒ Object



326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/axn/webhooks/outbound/signer.rb', line 326

def call(id:, timestamp:, body:, subscriber: nil)
  sig = Signature.compute(
    secret: decoded_secret(subscriber),
    payload: "#{id}.#{timestamp}.#{body}",
    digest: :sha256,
    encoding: :base64,
  )
  {
    "webhook-id" => id.to_s,
    "webhook-timestamp" => timestamp.to_s,
    "webhook-signature" => "v1,#{sig}",
  }
end