Class: Axn::Webhooks::Verifiers::BasicAuth

Inherits:
Object
  • Object
show all
Defined in:
lib/axn/webhooks/verifiers/basic_auth.rb

Overview

HTTP Basic auth (RFC 7617) as a verify strategy.

Unlike the signature strategies, Basic auth is a two-legged protocol: a client that does NOT authenticate preemptively sends its first request with no Authorization header, expects a 401 carrying WWW-Authenticate: Basic realm="…", and only then repeats the request with credentials. Twilio documents exactly this behaviour for webhook URLs, and it is why this strategy is a class rather than a bare lambda: #unauthorized_headers is what Endpoint#to_response attaches to the 401, and without it the second leg never happens and every webhook is silently dropped.

Constant Summary collapse

DEFAULT_REALM =
"Webhook"
CONTROL_CHARS =

A control character can't be represented in an RFC 7230 quoted-string at all, and CR/LF would split the response header outright. Rejected at declaration time (this runs inside Axn::Webhooks.inbound, i.e. at boot) rather than silently scrubbed: a realm is developer config, so a typo should fail the deploy, not ship a subtly malformed challenge.

/[\x00-\x1F\x7F]/

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(username:, password:, realm: DEFAULT_REALM) ⇒ BasicAuth

Returns a new instance of BasicAuth.



26
27
28
29
30
31
32
# File 'lib/axn/webhooks/verifiers/basic_auth.rb', line 26

def initialize(username:, password:, realm: DEFAULT_REALM)
  raise Axn::Webhooks::Error, "verify :basic_auth realm cannot contain control characters" if realm.to_s.match?(CONTROL_CHARS)

  @username = username
  @password = password
  @realm = realm.to_s
end

Instance Attribute Details

#realmObject (readonly)

Returns the value of attribute realm.



34
35
36
# File 'lib/axn/webhooks/verifiers/basic_auth.rb', line 34

def realm
  @realm
end

Instance Method Details

#call(request) ⇒ Object



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/axn/webhooks/verifiers/basic_auth.rb', line 46

def call(request)
  # Fail closed on a misconfigured deploy rather than comparing against "": an unset
  # credential pair would otherwise authenticate `Authorization: Basic Og==` for anyone.
  # Blank-but-present counts as missing — CI and secret managers can both set an empty
  # string. Raising (not returning false) makes it a reported exception, since a 401 that
  # means "we are misconfigured" is indistinguishable from one that means "you are not
  # Twilio" and would otherwise present as an unexplained outage.
  #
  # Guarded BEFORE any #to_s (security audit): coercing first let non-Strings through, and
  # `false.to_s` is the non-empty String "false" — so a `false` credential pair sailed past
  # an emptiness check and collapsed the login to the guessable constant `false:false`.
  expected_username = Verifiers.require_secret!("verify :basic_auth", Resolvers.resolve(@username, request), label: "username")
  expected_password = Verifiers.require_secret!("verify :basic_auth", Resolvers.resolve(@password, request), label: "password")

  username, password = credentials(request)
  # Absent/non-Basic credentials are reported apart from wrong ones. Under RFC 7617 a bare
  # first request is the handshake working, not a failure, so it's the highest-volume
  # rejection on a healthy endpoint — conflating it with a real credential problem would
  # bury the latter in expected traffic.
  return Signature::CREDENTIALS_MISSING unless username

  # `&` rather than `&&` so the comparison doesn't short-circuit on the username.
  matched = secure_compare(username, expected_username) & secure_compare(password, expected_password)
  matched ? Signature::OK : Signature::CREDENTIALS_MISMATCH
end

#challenge_required?(request) ⇒ Boolean

Is this request an authentication attempt at all? False for anything carrying an Authorization header — including a non-Basic scheme, which is a client that meant to authenticate and got it wrong, and stays a visible :credentials_missing rejection.

True only for the bare first leg of the RFC 7617 handshake, which Endpoint answers with the challenge instead of running Verify: there is nothing to verify, and a reactive client sends one of these per successful webhook, so recording them as verify failures made the highest-volume outcome on a healthy endpoint a failure (PRO-3148).

A blank header counts as absent — an empty Authorization presents no credentials and no scheme, so treating it as an attempt would 401 it with no telemetry either way.

Returns:

  • (Boolean)


83
# File 'lib/axn/webhooks/verifiers/basic_auth.rb', line 83

def challenge_required?(request) = request.header("Authorization").to_s.strip.empty?

#inspectObject

A verifier holds credentials by definition, and Verify's per-call logging renders its verifier: input — so the default Object#inspect would put the plaintext password in the application log on every single request. Same treatment Request gets, and for the same reason. The realm is safe (and useful) to show: it's already broadcast in the challenge.



40
# File 'lib/axn/webhooks/verifiers/basic_auth.rb', line 40

def inspect = "#<#{self.class.name} realm=#{realm.inspect} credentials=[REDACTED]>"

#pretty_print(printer) ⇒ Object

PP does not route through #inspect — Kernel#pretty_print walks instance variables directly — so without this pp verifier leaks exactly what #inspect just redacted.



44
# File 'lib/axn/webhooks/verifiers/basic_auth.rb', line 44

def pretty_print(printer) = printer.text(inspect)

#unauthorized_headersObject

The RFC 7617 challenge. Lower-cased key per Rack 3's response-header SPEC (Response lower-cases keys anyway; spelled that way here so the two agree on sight).

The realm is an RFC 7230 quoted-string, so " and \ are escaped rather than stripped — the realm a client displays should be the one that was configured. Stripping only quotes would leave a trailing backslash escaping the closing quote (realm="Partner\"), which is malformed enough that a client may reject the challenge and never retry — recreating the exact silent-drop failure this strategy exists to prevent.



93
94
95
# File 'lib/axn/webhooks/verifiers/basic_auth.rb', line 93

def unauthorized_headers
  { "www-authenticate" => %(Basic realm="#{realm.gsub(/([\\"])/, '\\\\\1')}") }
end