Class: Axn::Webhooks::Outbound::Signer::HmacSigner

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

Overview

Parametric HMAC, the outbound face of verify :hmac. Emits ONE signature header plus an optional timestamp header. header: is required: unlike Standard Webhooks there is no universal header name, which is exactly why the inbound verifier requires signature:.

Constant Summary collapse

PLACEHOLDERS =
%w[timestamp body].freeze
DEFAULT_SIGNING_STRING =
"{body}"

Instance Method Summary collapse

Constructor Details

#initialize(secret:, header:, digest: :sha256, encoding: :hex, prefix: nil, signing_string: DEFAULT_SIGNING_STRING, timestamp_header: nil) ⇒ HmacSigner

Returns a new instance of HmacSigner.

Raises:

  • (ArgumentError)


110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/axn/webhooks/outbound/signer.rb', line 110

def initialize(secret:, header:, digest: :sha256, encoding: :hex, prefix: nil,
               signing_string: DEFAULT_SIGNING_STRING, timestamp_header: nil)
  validate_header_name!(:header, header)
  unless timestamp_header.nil?
    validate_header_name!(:timestamp_header, timestamp_header)
    # The timestamp assignment in `call` lands SECOND and would overwrite the signature,
    # shipping every delivery unverifiable — silently. HTTP header names are
    # case-insensitive, so compare that way (Codex review).
    if header.casecmp?(timestamp_header)
      raise ArgumentError,
            "sign :hmac `header:` and `timestamp_header:` are the same header name " \
            "(#{header.inspect}) — the timestamp would overwrite the signature"
    end
  end

  # Same reasoning as :standard_webhooks — `resolved_secret` calls with NO arguments, or
  # with the PRO-3214 `Subscriber` for a 1-arity per-subscriber secret. A callable needing
  # MORE than that boots fine and raises on every real signing attempt.
  if secret.respond_to?(:call) && !(CallableArity.accepts?(secret, 0) || CallableArity.accepts?(secret, 1))
    raise ArgumentError,
          "sign :hmac secret callable must accept zero or one arguments (resolved with no " \
          "args, or the Subscriber, per signing attempt)"
  end

  # Both are finite sets in Signature; an unvalidated typo boots fine and then raises
  # inside EVERY delivery attempt — on the async path, after the job is enqueued, so it
  # retries the same broken config (Codex review).
  raise ArgumentError, "sign :hmac unsupported digest: #{digest.inspect}" unless Signature::DIGESTS.key?(digest)
  raise ArgumentError, "sign :hmac unsupported encoding: #{encoding.inspect}" unless Signature::ENCODINGS.include?(encoding)

  validate_template!(signing_string, timestamp_header)

  # Copy every String we validated or emit. Validation runs ONCE, here; retaining the
  # caller's mutable object lets an app change what ships afterwards —
  # `header.replace("Content-Type")` walks straight past both the field-name grammar and
  # the MANAGED_HEADERS collision rule, and Deliver then overwrites the signature (Codex
  # review). Same validate-then-alias shape as Config's static `to:` array.
  @secret = secret # NOT copied: may be a callable, and a String secret is re-read per call anyway
  @header = dup_frozen(header)
  @digest = digest
  @encoding = encoding
  @prefix = dup_frozen(prefix)
  @signing_string = dup_frozen(signing_string)
  @timestamp_header = dup_frozen(timestamp_header)
end

Instance Method Details

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

id: is part of the signer contract but unused here — an id-bearing signature is what :standard_webhooks is for, and this preset emits no id header for a receiver to read one back from. Absorbed by ** rather than named, so it isn't an unused argument.



159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/axn/webhooks/outbound/signer.rb', line 159

def call(timestamp:, body:, subscriber: nil, **)
  sig = Signature.compute(
    secret: resolved_secret(subscriber),
    payload: render(timestamp:, body:),
    digest: @digest,
    encoding: @encoding,
  )

  headers = { @header => "#{@prefix}#{sig}" }
  headers[@timestamp_header] = timestamp.to_s if @timestamp_header
  headers
end