Skip to content

Commit c9641fb

Browse files
authored
Merge pull request #571 from koic/parse_www_authenticate_in_linear_time
Parse the WWW-Authenticate challenge in linear time
2 parents b56d007 + 6a88d8e commit c9641fb

2 files changed

Lines changed: 62 additions & 16 deletions

File tree

‎lib/mcp/client/oauth/discovery.rb‎

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "ipaddr"
4+
require "strscan"
45
require "uri"
56

67
module MCP
@@ -43,6 +44,12 @@ module Discovery
4344
# or a bare token, per RFC 7235.
4445
WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze
4546

47+
# The whitespace and optional comma between two `key=value` pairs, or before the first one.
48+
WWW_AUTH_PARAM_SEPARATOR_PATTERN = /\s*,?\s*/.freeze
49+
50+
# The `Bearer` challenge: at the start of the header or after a comma.
51+
WWW_AUTH_BEARER_PATTERN = /(?:\A|,)\s*Bearer(?:\s+|\z)/i.freeze
52+
4653
class << self
4754
# Parses a `WWW-Authenticate` header and returns the parameters of
4855
# the `Bearer` challenge as a hash with lower-cased keys (e.g. `resource_metadata`,
@@ -55,25 +62,21 @@ class << self
5562
def parse_www_authenticate(header)
5663
return {} unless header
5764

58-
# Locate the Bearer challenge: at the start of the header or after a comma.
59-
bearer = header.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i)
60-
return {} unless bearer
61-
6265
# Walk key=value pairs starting where Bearer's parameters begin.
63-
# The loop stops at the first token that is not a key=value pair,
64-
# which marks the next challenge (e.g. `, DPoP algs="..."`).
65-
cursor = bearer.end(0)
66-
params = {}
67-
while cursor < header.length
68-
prefix = header[cursor..]
69-
prefix = prefix.sub(/\A\s*,?\s*/, "")
70-
break if prefix.empty?
66+
# The loop stops at the first token that is not a key=value pair, which marks the next challenge (e.g. `, DPoP algs="..."`).
67+
# The scanner keeps the walk linear in the header's length: slicing off the consumed prefix instead copies the remainder
68+
# for every pair, and the server chooses how many pairs it sends. The header is also the server's to fill: a byte sequence
69+
# that is not valid in the string's encoding would make the patterns raise `ArgumentError`, so such bytes are replaced first.
70+
scanner = StringScanner.new(header.scrub)
71+
return {} unless scanner.skip_until(WWW_AUTH_BEARER_PATTERN)
7172

72-
match = prefix.match(WWW_AUTH_PARAM_PATTERN)
73-
break unless match
73+
params = {}
74+
until scanner.eos?
75+
scanner.skip(WWW_AUTH_PARAM_SEPARATOR_PATTERN)
76+
break if scanner.eos?
77+
break unless scanner.scan(WWW_AUTH_PARAM_PATTERN)
7478

75-
params[match[1].downcase] = match[2] ? unescape_quoted_pair(match[2]) : match[3]
76-
cursor = header.length - prefix.length + match.end(0)
79+
params[scanner[1].downcase] = scanner[2] ? unescape_quoted_pair(scanner[2]) : scanner[3]
7780
end
7881
params
7982
end

‎test/mcp/client/oauth/discovery_test.rb‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,49 @@ def test_parse_www_authenticate_unescapes_quoted_pair
7979
assert_equal('value with "quoted" word and a back\\slash', params["error_description"])
8080
end
8181

82+
def test_parse_www_authenticate_finds_bearer_after_a_challenge_with_multibyte_text
83+
# The Bearer challenge is located by byte position, so text before it that is wider than
84+
# one byte per character must not shift where its parameters are read from.
85+
params = Discovery.parse_www_authenticate(%(Basic realm="café", Bearer scope="s"))
86+
87+
assert_equal({ "scope" => "s" }, params)
88+
end
89+
90+
def test_parse_www_authenticate_tolerates_bytes_that_are_invalid_in_the_header_encoding
91+
# A value the server fills with bytes that are not valid UTF-8 must not turn the `401` into
92+
# an `ArgumentError`; the bytes are replaced and the other parameters still come through.
93+
header = %(Bearer error="invalid_token", scope="s\xff", realm="r").dup.force_encoding(Encoding::UTF_8)
94+
95+
params = Discovery.parse_www_authenticate(header)
96+
97+
assert_equal("invalid_token", params["error"])
98+
assert_equal("s�", params["scope"])
99+
assert_equal("r", params["realm"])
100+
end
101+
102+
def test_parse_www_authenticate_reads_a_binary_header
103+
# Net::HTTP hands header values over as ASCII-8BIT; high bytes are kept as they are.
104+
params = Discovery.parse_www_authenticate(%(Bearer scope="s\xff", realm="r").b)
105+
106+
assert_equal("s\xff".b, params["scope"])
107+
assert_equal("r", params["realm"])
108+
end
109+
110+
def test_parse_www_authenticate_walks_a_header_with_many_parameters_in_linear_time
111+
# The header is the server's to choose. Slicing off the consumed prefix for every pair copied
112+
# the remainder each time, so 200,000 pairs took tens of seconds; the bound below is loose enough
113+
# for a slow CI machine and far below that.
114+
header = "Bearer " + (1..200_000).map { |i| %(k#{i}="v#{i}") }.join(", ")
115+
116+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
117+
params = Discovery.parse_www_authenticate(header)
118+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
119+
120+
assert_equal(200_000, params.size)
121+
assert_equal("v200000", params["k200000"])
122+
assert_operator(elapsed, :<, 5)
123+
end
124+
82125
def test_protected_resource_metadata_urls_uses_explicit_url_first
83126
urls = Discovery.protected_resource_metadata_urls(
84127
server_url: "https://api.example.com/mcp",

0 commit comments

Comments
 (0)