Skip to content

Commit 2e2e819

Browse files
committed
fix(auth): parse WWW-Authenticate params quote-aware
1 parent b8b6001 commit 2e2e819

2 files changed

Lines changed: 99 additions & 8 deletions

File tree

src/mcp/client/auth/utils.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import re
21
from urllib.parse import urljoin, urlparse
32

43
from httpx import Request, Response
@@ -16,6 +15,45 @@
1615
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
1716

1817

18+
def _iter_www_auth_params(www_auth_header: str) -> list[str]:
19+
"""Split a WWW-Authenticate challenge into auth-param tokens."""
20+
params_start = www_auth_header.find(" ")
21+
if params_start == -1:
22+
return []
23+
24+
params: list[str] = []
25+
current: list[str] = []
26+
in_quotes = False
27+
escape_next = False
28+
29+
for char in www_auth_header[params_start + 1 :]:
30+
if escape_next:
31+
current.append(char)
32+
escape_next = False
33+
continue
34+
if char == "\\" and in_quotes:
35+
current.append(char)
36+
escape_next = True
37+
continue
38+
if char == '"':
39+
in_quotes = not in_quotes
40+
current.append(char)
41+
continue
42+
if char == "," and not in_quotes:
43+
param = "".join(current).strip()
44+
if param:
45+
params.append(param)
46+
current = []
47+
continue
48+
current.append(char)
49+
50+
param = "".join(current).strip()
51+
if param:
52+
params.append(param)
53+
54+
return params
55+
56+
1957
def extract_field_from_www_auth(response: Response, field_name: str) -> str | None:
2058
"""Extract field from WWW-Authenticate header.
2159
@@ -26,14 +64,16 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No
2664
if not www_auth_header:
2765
return None
2866

29-
# Pattern matches a complete auth-param name, not a suffix of another
30-
# parameter such as error_scope or x_resource_metadata.
31-
pattern = rf'(?:^|[\s,]){re.escape(field_name)}=(?:"([^"]+)"|([^\s,]+))'
32-
match = re.search(pattern, www_auth_header)
67+
for param in _iter_www_auth_params(www_auth_header):
68+
name, separator, value = param.partition("=")
69+
if separator != "=" or name.strip() != field_name:
70+
continue
3371

34-
if match:
35-
# Return quoted value if present, otherwise unquoted value
36-
return match.group(1) or match.group(2)
72+
value = value.strip()
73+
if len(value) >= 2 and value[0] == value[-1] == '"':
74+
value = value[1:-1]
75+
if value:
76+
return value
3777

3878
return None
3979

tests/client/test_auth.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2118,6 +2118,57 @@ def test_extract_resource_metadata_from_www_auth_ignores_prefixed_param(
21182118
result = extract_resource_metadata_from_www_auth(init_response)
21192119
assert result is None
21202120

2121+
def test_extract_field_from_www_auth_ignores_param_like_text_inside_quoted_value(
2122+
self,
2123+
client_metadata: OAuthClientMetadata,
2124+
mock_storage: MockTokenStorage,
2125+
):
2126+
"""Test quoted values cannot shadow a later auth-param with the same name."""
2127+
2128+
init_response = httpx.Response(
2129+
status_code=401,
2130+
headers={"WWW-Authenticate": 'Bearer realm="api, scope=decoy", scope="read write"'},
2131+
request=httpx.Request("GET", "https://api.example.com/test"),
2132+
)
2133+
2134+
result = extract_field_from_www_auth(init_response, "scope")
2135+
assert result == "read write"
2136+
2137+
def test_extract_field_from_www_auth_ignores_quoted_value_when_only_decoy_exists(
2138+
self,
2139+
client_metadata: OAuthClientMetadata,
2140+
mock_storage: MockTokenStorage,
2141+
):
2142+
"""Test a field-like string inside a quoted value is not an auth-param."""
2143+
2144+
init_response = httpx.Response(
2145+
status_code=401,
2146+
headers={"WWW-Authenticate": 'Bearer realm="api scope=leaked"'},
2147+
request=httpx.Request("GET", "https://api.example.com/test"),
2148+
)
2149+
2150+
result = extract_field_from_www_auth(init_response, "scope")
2151+
assert result is None
2152+
2153+
def test_extract_resource_metadata_from_www_auth_ignores_quoted_value_decoy(
2154+
self,
2155+
client_metadata: OAuthClientMetadata,
2156+
mock_storage: MockTokenStorage,
2157+
):
2158+
"""Test resource_metadata is not extracted from another quoted param value."""
2159+
2160+
init_response = httpx.Response(
2161+
status_code=401,
2162+
headers={
2163+
"WWW-Authenticate": 'Bearer realm="api, resource_metadata=https://decoy.example.com", '
2164+
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
2165+
},
2166+
request=httpx.Request("GET", "https://api.example.com/test"),
2167+
)
2168+
2169+
result = extract_resource_metadata_from_www_auth(init_response)
2170+
assert result == "https://api.example.com/.well-known/oauth-protected-resource"
2171+
21212172

21222173
class TestCIMD:
21232174
"""Test Client ID Metadata Document (CIMD) support."""

0 commit comments

Comments
 (0)