Skip to content

Commit 1d09399

Browse files
Handle quoted WWW-Authenticate auth-param values
1 parent 9d9357b commit 1d09399

2 files changed

Lines changed: 87 additions & 9 deletions

File tree

src/mcp/client/auth/utils.py

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import re
1+
from collections.abc import Iterator
22
from urllib.parse import urljoin, urlparse
33

44
from httpx import Request, Response
@@ -16,6 +16,76 @@
1616
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
1717

1818

19+
def _read_quoted_www_auth_value(header: str, value_start: int) -> tuple[str | None, int]:
20+
value_chars: list[str] = []
21+
escaped = False
22+
index = value_start + 1
23+
24+
while index < len(header):
25+
char = header[index]
26+
if escaped:
27+
value_chars.append(char)
28+
escaped = False
29+
elif char == "\\":
30+
escaped = True
31+
elif char == '"':
32+
value = "".join(value_chars)
33+
return (value or None), index + 1
34+
else:
35+
value_chars.append(char)
36+
index += 1
37+
38+
return None, len(header)
39+
40+
41+
def _read_www_auth_value(header: str, value_start: int) -> tuple[str | None, int]:
42+
while value_start < len(header) and header[value_start] in " \t":
43+
value_start += 1
44+
45+
if value_start >= len(header):
46+
return None, value_start
47+
48+
if header[value_start] == '"':
49+
return _read_quoted_www_auth_value(header, value_start)
50+
51+
value_end = value_start
52+
while value_end < len(header) and header[value_end] not in " \t,":
53+
value_end += 1
54+
55+
if value_end == value_start:
56+
return None, value_end
57+
58+
return header[value_start:value_end], value_end
59+
60+
61+
def _iter_www_auth_params(header: str) -> Iterator[tuple[str, str]]:
62+
index = 0
63+
while index < len(header):
64+
while index < len(header) and header[index] in " \t,":
65+
index += 1
66+
67+
name_start = index
68+
while index < len(header) and header[index] not in " \t=,":
69+
index += 1
70+
71+
if index == name_start:
72+
index += 1
73+
continue
74+
75+
name = header[name_start:index]
76+
value_start = index
77+
while value_start < len(header) and header[value_start] in " \t":
78+
value_start += 1
79+
80+
if value_start >= len(header) or header[value_start] != "=":
81+
index = value_start
82+
continue
83+
84+
value, index = _read_www_auth_value(header, value_start + 1)
85+
if value is not None:
86+
yield name, value
87+
88+
1989
def extract_field_from_www_auth(response: Response, field_name: str) -> str | None:
2090
"""Extract field from WWW-Authenticate header.
2191
@@ -26,14 +96,9 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No
2696
if not www_auth_header:
2797
return None
2898

29-
# Pattern matches complete auth-param names: field_name="value" or field_name=value (unquoted).
30-
field_pattern = re.escape(field_name)
31-
pattern = rf'(?:^|[\s,]){field_pattern}=(?:"([^"]+)"|([^\s,]+))'
32-
match = re.search(pattern, www_auth_header)
33-
34-
if match:
35-
# Return quoted value if present, otherwise unquoted value
36-
return match.group(1) or match.group(2)
99+
for name, value in _iter_www_auth_params(www_auth_header):
100+
if name == field_name:
101+
return value
37102

38103
return None
39104

tests/client/test_auth.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2007,12 +2007,19 @@ class TestWWWAuthenticate:
20072007
('Bearer realm="api", scope=basic', "scope", "basic"),
20082008
# Decoy parameter name before the real field
20092009
('Bearer error_scope="decoy", scope="read write"', "scope", "read write"),
2010+
('Bearer error_description="missing scope=wrong", scope="read write"', "scope", "read write"),
20102011
(
20112012
'Bearer x_resource_metadata="https://decoy.example.com", '
20122013
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"',
20132014
"resource_metadata",
20142015
"https://api.example.com/.well-known/oauth-protected-resource",
20152016
),
2017+
(
2018+
'Bearer error_description="missing resource_metadata=https://decoy.example.com", '
2019+
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"',
2020+
"resource_metadata",
2021+
"https://api.example.com/.well-known/oauth-protected-resource",
2022+
),
20162023
# Values with special characters
20172024
(
20182025
'Bearer scope="resource:read resource:write user_profile"',
@@ -2056,11 +2063,17 @@ def test_extract_field_from_www_auth_valid_cases(
20562063
('Bearer realm="api", error="insufficient_scope"', "scope", "no scope parameter"),
20572064
('Bearer realm="api", scope="read write"', "resource_metadata", "no resource_metadata parameter"),
20582065
('Bearer custom_scope="leaked"', "scope", "substring param name should not match scope"),
2066+
('Bearer error_description="missing scope=wrong"', "scope", "field-like text in quoted value"),
20592067
(
20602068
'Bearer x_resource_metadata="https://decoy.example.com"',
20612069
"resource_metadata",
20622070
"substring param name should not match resource_metadata",
20632071
),
2072+
(
2073+
'Bearer error_description="missing resource_metadata=https://decoy.example.com"',
2074+
"resource_metadata",
2075+
"field-like text in quoted value",
2076+
),
20642077
# Malformed field (empty value)
20652078
("Bearer scope=", "scope", "malformed scope parameter"),
20662079
("Bearer resource_metadata=", "resource_metadata", "malformed resource_metadata parameter"),

0 commit comments

Comments
 (0)