Skip to content

Commit d4cfbc5

Browse files
committed
Address WWW-Authenticate parser review feedback
1 parent 3e99ffe commit d4cfbc5

2 files changed

Lines changed: 122 additions & 43 deletions

File tree

src/mcp/client/auth/utils.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,14 @@ def _split_www_authenticate_segments(header_value: str) -> list[str]:
2121
segments: list[str] = []
2222
current: list[str] = []
2323
in_quotes = False
24+
escaped = False
2425

2526
for char in header_value:
26-
if char == '"':
27+
if escaped:
28+
escaped = False
29+
elif char == "\\" and in_quotes:
30+
escaped = True
31+
elif char == '"':
2732
in_quotes = not in_quotes
2833
if char == "," and not in_quotes:
2934
segment = "".join(current).strip()
@@ -64,6 +69,35 @@ def _extract_bearer_auth_params(www_auth_header: str) -> str | None:
6469
return ", ".join(part for part in auth_params if part)
6570

6671

72+
_AUTH_PARAM_PATTERN = re.compile(
73+
r"(?:^|,\s*)(?P<name>[A-Za-z][A-Za-z0-9_-]*)\s*=\s*"
74+
r'(?:"(?P<quoted>(?:\\.|[^"\\])*)"|(?P<unquoted>[^,\s]+))'
75+
)
76+
77+
78+
def _extract_auth_param(auth_params: str, field_name: str) -> str | None:
79+
"""Extract an auth-param from a comma-delimited parameter string."""
80+
for match in _AUTH_PARAM_PATTERN.finditer(auth_params):
81+
if match.group("name") != field_name:
82+
continue
83+
quoted = match.group("quoted")
84+
value = quoted if quoted is not None else match.group("unquoted")
85+
if not value:
86+
return None
87+
return re.sub(r"\\(.)", r"\1", value) if quoted is not None else value
88+
return None
89+
90+
91+
def _extract_field_from_bearer_challenge(response: Response, field_name: str) -> str | None:
92+
"""Extract an auth-param from the first Bearer challenge."""
93+
www_auth_header = response.headers.get("WWW-Authenticate")
94+
if not www_auth_header:
95+
return None
96+
97+
auth_params = _extract_bearer_auth_params(www_auth_header)
98+
return _extract_auth_param(auth_params, field_name) if auth_params is not None else None
99+
100+
67101
def extract_field_from_www_auth(response: Response, field_name: str) -> str | None:
68102
"""Extract field from WWW-Authenticate header.
69103
@@ -74,18 +108,11 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No
74108
if not www_auth_header:
75109
return None
76110

77-
auth_params = _extract_bearer_auth_params(www_auth_header)
78-
if auth_params is None:
79-
return None
80-
81-
# Match comma-delimited auth-params while respecting quoted values.
82-
pattern = re.compile(
83-
r'(?:^|,\s*)(?P<name>[A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"(?P<quoted>[^"]*)"|(?P<unquoted>[^,\s]+))'
84-
)
85-
for match in pattern.finditer(auth_params):
86-
if match.group("name") == field_name:
87-
# Return quoted value if present, otherwise unquoted value
88-
return match.group("quoted") or match.group("unquoted")
111+
for segment in _split_www_authenticate_segments(www_auth_header):
112+
scheme, separator, remainder = segment.partition(" ")
113+
auth_params = remainder.strip() if separator and "=" not in scheme else segment
114+
if value := _extract_auth_param(auth_params, field_name):
115+
return value
89116

90117
return None
91118

@@ -96,7 +123,7 @@ def extract_scope_from_www_auth(response: Response) -> str | None:
96123
Returns:
97124
Scope string if found in WWW-Authenticate header, None otherwise
98125
"""
99-
return extract_field_from_www_auth(response, "scope")
126+
return _extract_field_from_bearer_challenge(response, "scope")
100127

101128

102129
def extract_resource_metadata_from_www_auth(response: Response) -> str | None:
@@ -108,7 +135,7 @@ def extract_resource_metadata_from_www_auth(response: Response) -> str | None:
108135
if not response or response.status_code != 401:
109136
return None # pragma: no cover
110137

111-
return extract_field_from_www_auth(response, "resource_metadata")
138+
return _extract_field_from_bearer_challenge(response, "resource_metadata")
112139

113140

114141
def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]:

tests/client/test_auth.py

Lines changed: 80 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -181,37 +181,89 @@ def test_pkce_uniqueness(self):
181181
assert pkce1.code_challenge != pkce2.code_challenge
182182

183183

184-
class TestExtractFieldFromWwwAuth:
185-
def test_uses_first_bearer_challenge_when_multiple_are_present(self):
186-
response = httpx.Response(
187-
401,
188-
headers={
189-
"WWW-Authenticate": (
190-
'Basic realm="legacy", Bearer scope="read", error="insufficient_scope", Bearer scope="write"'
191-
)
192-
},
193-
request=httpx.Request("GET", "https://example.com"),
194-
)
184+
def test_scope_uses_first_bearer_challenge_when_multiple_are_present():
185+
"""RFC 6750 fields come from the first Bearer challenge, not another scheme or later challenge."""
186+
response = httpx.Response(
187+
401,
188+
headers={
189+
"WWW-Authenticate": (
190+
'Basic scope="legacy", Bearer scope="read", error="insufficient_scope", Bearer scope="write"'
191+
)
192+
},
193+
request=httpx.Request("GET", "https://example.com"),
194+
)
195195

196-
assert extract_scope_from_www_auth(response) == "read"
196+
assert extract_scope_from_www_auth(response) == "read"
197197

198-
def test_supports_optional_whitespace_around_equals(self):
199-
response = httpx.Response(
200-
401,
201-
headers={
202-
"WWW-Authenticate": (
203-
'Bearer error="insufficient_scope", scope = "read write", '
204-
'resource_metadata = "https://example.com/.well-known/oauth-protected-resource"'
205-
)
206-
},
207-
request=httpx.Request("GET", "https://example.com"),
208-
)
209198

210-
assert extract_scope_from_www_auth(response) == "read write"
211-
assert (
212-
extract_resource_metadata_from_www_auth(response)
213-
== "https://example.com/.well-known/oauth-protected-resource"
214-
)
199+
def test_bearer_fields_support_optional_whitespace_around_equals():
200+
"""Bearer auth-params allow optional whitespace around the equals sign."""
201+
response = httpx.Response(
202+
401,
203+
headers={
204+
"WWW-Authenticate": (
205+
'Bearer error="insufficient_scope", scope = "read write", '
206+
'resource_metadata = "https://example.com/.well-known/oauth-protected-resource"'
207+
)
208+
},
209+
request=httpx.Request("GET", "https://example.com"),
210+
)
211+
212+
assert extract_scope_from_www_auth(response) == "read write"
213+
assert (
214+
extract_resource_metadata_from_www_auth(response) == "https://example.com/.well-known/oauth-protected-resource"
215+
)
216+
217+
218+
def test_generic_field_lookup_preserves_non_bearer_challenges():
219+
"""The generic helper keeps its pre-existing ability to read fields from other auth schemes."""
220+
response = httpx.Response(
221+
401,
222+
headers={"WWW-Authenticate": 'Newauth realm="apps"'},
223+
request=httpx.Request("GET", "https://example.com"),
224+
)
225+
226+
assert extract_field_from_www_auth(response, "realm") == "apps"
227+
assert extract_scope_from_www_auth(response) is None
228+
229+
230+
def test_quoted_auth_params_handle_escaped_quotes_and_commas():
231+
"""RFC quoted-pairs do not terminate a quoted value or split its embedded comma."""
232+
response = httpx.Response(
233+
401,
234+
headers={
235+
"WWW-Authenticate": (
236+
'Newauth realm="apps \\"beta\\", east", '
237+
'Bearer error_description="try \\"again\\", later", scope="read write"'
238+
)
239+
},
240+
request=httpx.Request("GET", "https://example.com"),
241+
)
242+
243+
assert extract_field_from_www_auth(response, "realm") == 'apps "beta", east'
244+
assert extract_scope_from_www_auth(response) == "read write"
245+
246+
247+
def test_bearer_parser_ignores_empty_segments_and_stops_at_next_challenge():
248+
"""The first Bearer challenge ends when another authentication scheme begins."""
249+
response = httpx.Response(
250+
401,
251+
headers={"WWW-Authenticate": ', Bearer scope="read", Basic realm="legacy",'},
252+
request=httpx.Request("GET", "https://example.com"),
253+
)
254+
255+
assert extract_scope_from_www_auth(response) == "read"
256+
257+
258+
def test_empty_quoted_auth_param_returns_none():
259+
"""An empty quoted auth-param has the same missing-value result as an unquoted empty parameter."""
260+
response = httpx.Response(
261+
401,
262+
headers={"WWW-Authenticate": 'Bearer scope=""'},
263+
request=httpx.Request("GET", "https://example.com"),
264+
)
265+
266+
assert extract_scope_from_www_auth(response) is None
215267

216268

217269
class TestOAuthContext:

0 commit comments

Comments
 (0)