Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 67 additions & 3 deletions lib/mpp/challenge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,81 @@ def self.from_www_authenticate(header)
# Parse multiple Payment challenges from a merged WWW-Authenticate header.
# Handles RFC 9110 §11.6.1 comma-separated authentication schemes.
def self.from_www_authenticate_list(header)
indices = []
header.scan(/Payment\s+/i) { indices << T.must(Regexp.last_match).begin(0) }
indices = payment_scheme_indices(header)
return [] if indices.empty?

indices.each_with_index.map do |start_idx, i|
end_idx = (i + 1 < indices.length) ? indices[i + 1] : header.length
end_idx = if i + 1 < indices.length
indices[i + 1]
else
next_auth_scheme_index(header, start_idx + "Payment".length) || header.length
end
chunk = T.must(header[start_idx...end_idx]).sub(/,\s*$/, "")
from_www_authenticate(chunk)
end
end

def self.payment_scheme_indices(header)
indices = []
each_auth_scheme_index(header) do |index, scheme|
indices << index if scheme.casecmp("Payment").zero?
end
indices
end

def self.next_auth_scheme_index(header, offset)
each_auth_scheme_index(header, offset) do |index, _scheme|
return index
end
nil
end

def self.each_auth_scheme_index(header, offset = 0)
in_quote = false
escaped = false
i = offset

while i < header.length
char = T.must(header[i])

if in_quote
if escaped
escaped = false
elsif char == "\\"
escaped = true
elsif char == "\""
in_quote = false
end
i += 1
next
end

if char == "\""
in_quote = true
i += 1
next
end

if scheme_boundary?(header, i)
match = T.must(header[i..]).match(/\A([A-Za-z][A-Za-z0-9._~+\/-]*)\s+/)
if match
yield i, T.must(match[1])
i += T.must(match[0]).length
next
end
end

i += 1
end
end

def self.scheme_boundary?(header, index)
return true if index == 0

previous = T.must(header[0...index]).rstrip
previous.end_with?(",")
end

# Serialize to a WWW-Authenticate header value.
def to_www_authenticate(realm)
Mpp::Parsing.format_www_authenticate(self, realm)
Expand Down
39 changes: 39 additions & 0 deletions test/mpp/test_parsing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,45 @@ def test_from_www_authenticate_list_multiple
assert_equal "other", result[1].method
end

def test_from_www_authenticate_list_ignores_payment_scheme_inside_quotes
c1 = Mpp::Challenge.create(
secret_key: "s1",
realm: "api, Payment realm",
method: "tempo",
intent: "charge",
request: {"amount" => "100"}
)
c2 = Mpp::Challenge.create(
secret_key: "s2",
realm: "api.example.com",
method: "stripe",
intent: "charge",
request: {"amount" => "200"}
)
header = "#{c1.to_www_authenticate("api, Payment realm")}, #{c2.to_www_authenticate("api.example.com")}"
result = Mpp::Challenge.from_www_authenticate_list(header)

assert_equal 2, result.length
assert_equal "api, Payment realm", result[0].realm
assert_equal "tempo", result[0].method
assert_equal "stripe", result[1].method
end

def test_from_www_authenticate_list_stops_before_next_non_payment_scheme
challenge = Mpp::Challenge.create(
secret_key: "test-secret",
realm: "api.example.com",
method: "tempo",
intent: "charge",
request: {"amount" => "1000000"}
)
header = "#{challenge.to_www_authenticate("api.example.com")}, Bearer realm=\"fallback\""
result = Mpp::Challenge.from_www_authenticate_list(header)

assert_equal 1, result.length
assert_equal challenge.id, result[0].id
end

def test_from_www_authenticate_list_empty
assert_equal [], Mpp::Challenge.from_www_authenticate_list("Bearer token123")
assert_equal [], Mpp::Challenge.from_www_authenticate_list("")
Expand Down