From 5ce7010ba43f9936d868a872aa3159d879c0be44 Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Wed, 1 Jul 2026 21:33:21 -0400 Subject: [PATCH 1/4] fix: match complete www-authenticate auth params --- src/mcp/client/auth/utils.py | 5 +++-- tests/client/test_auth.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index d6b05e0667..c0df44f104 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -26,8 +26,9 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No if not www_auth_header: return None - # Pattern matches: field_name="value" or field_name=value (unquoted) - pattern = rf'{field_name}=(?:"([^"]+)"|([^\s,]+))' + # Pattern matches complete auth-param names: field_name="value" or field_name=value (unquoted). + field_pattern = re.escape(field_name) + pattern = rf'(?:^|[\s,]){field_pattern}=(?:"([^"]+)"|([^\s,]+))' match = re.search(pattern, www_auth_header) if match: diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 1ec38ccf6f..365612f25a 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2005,6 +2005,14 @@ class TestWWWAuthenticate: ), # Multiple parameters with unquoted value ('Bearer realm="api", scope=basic', "scope", "basic"), + # Decoy parameter name before the real field + ('Bearer error_scope="decoy", scope="read write"', "scope", "read write"), + ( + 'Bearer x_resource_metadata="https://decoy.example.com", ' + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"', + "resource_metadata", + "https://api.example.com/.well-known/oauth-protected-resource", + ), # Values with special characters ( 'Bearer scope="resource:read resource:write user_profile"', @@ -2047,6 +2055,12 @@ def test_extract_field_from_www_auth_valid_cases( # Header without requested field ('Bearer realm="api", error="insufficient_scope"', "scope", "no scope parameter"), ('Bearer realm="api", scope="read write"', "resource_metadata", "no resource_metadata parameter"), + ('Bearer custom_scope="leaked"', "scope", "substring param name should not match scope"), + ( + 'Bearer x_resource_metadata="https://decoy.example.com"', + "resource_metadata", + "substring param name should not match resource_metadata", + ), # Malformed field (empty value) ("Bearer scope=", "scope", "malformed scope parameter"), ("Bearer resource_metadata=", "resource_metadata", "malformed resource_metadata parameter"), @@ -2070,6 +2084,18 @@ def test_extract_field_from_www_auth_invalid_cases( result = extract_field_from_www_auth(init_response, field_name) assert result is None, f"Should return None for {description}" + def test_extract_resource_metadata_from_www_auth_ignores_substring_param_name(self): + """Test resource_metadata extraction ignores auth-params with longer names.""" + init_response = httpx.Response( + status_code=401, + headers={"WWW-Authenticate": 'Bearer x_resource_metadata="https://decoy.example.com"'}, + request=httpx.Request("GET", "https://api.example.com/test"), + ) + + result = extract_resource_metadata_from_www_auth(init_response) + + assert result is None + class TestCIMD: """Test Client ID Metadata Document (CIMD) support.""" From 1d09399c223cf1cae5df8c77d1569b972ad51431 Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Sun, 19 Jul 2026 03:28:31 -0400 Subject: [PATCH 2/4] Handle quoted WWW-Authenticate auth-param values --- src/mcp/client/auth/utils.py | 83 ++++++++++++++++++++++++++++++++---- tests/client/test_auth.py | 13 ++++++ 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index c0df44f104..7bd2ffac52 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -1,4 +1,4 @@ -import re +from collections.abc import Iterator from urllib.parse import urljoin, urlparse from httpx import Request, Response @@ -16,6 +16,76 @@ from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +def _read_quoted_www_auth_value(header: str, value_start: int) -> tuple[str | None, int]: + value_chars: list[str] = [] + escaped = False + index = value_start + 1 + + while index < len(header): + char = header[index] + if escaped: + value_chars.append(char) + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + value = "".join(value_chars) + return (value or None), index + 1 + else: + value_chars.append(char) + index += 1 + + return None, len(header) + + +def _read_www_auth_value(header: str, value_start: int) -> tuple[str | None, int]: + while value_start < len(header) and header[value_start] in " \t": + value_start += 1 + + if value_start >= len(header): + return None, value_start + + if header[value_start] == '"': + return _read_quoted_www_auth_value(header, value_start) + + value_end = value_start + while value_end < len(header) and header[value_end] not in " \t,": + value_end += 1 + + if value_end == value_start: + return None, value_end + + return header[value_start:value_end], value_end + + +def _iter_www_auth_params(header: str) -> Iterator[tuple[str, str]]: + index = 0 + while index < len(header): + while index < len(header) and header[index] in " \t,": + index += 1 + + name_start = index + while index < len(header) and header[index] not in " \t=,": + index += 1 + + if index == name_start: + index += 1 + continue + + name = header[name_start:index] + value_start = index + while value_start < len(header) and header[value_start] in " \t": + value_start += 1 + + if value_start >= len(header) or header[value_start] != "=": + index = value_start + continue + + value, index = _read_www_auth_value(header, value_start + 1) + if value is not None: + yield name, value + + def extract_field_from_www_auth(response: Response, field_name: str) -> str | None: """Extract field from WWW-Authenticate header. @@ -26,14 +96,9 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No if not www_auth_header: return None - # Pattern matches complete auth-param names: field_name="value" or field_name=value (unquoted). - field_pattern = re.escape(field_name) - pattern = rf'(?:^|[\s,]){field_pattern}=(?:"([^"]+)"|([^\s,]+))' - match = re.search(pattern, www_auth_header) - - if match: - # Return quoted value if present, otherwise unquoted value - return match.group(1) or match.group(2) + for name, value in _iter_www_auth_params(www_auth_header): + if name == field_name: + return value return None diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 365612f25a..5b4ce1a35c 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2007,12 +2007,19 @@ class TestWWWAuthenticate: ('Bearer realm="api", scope=basic', "scope", "basic"), # Decoy parameter name before the real field ('Bearer error_scope="decoy", scope="read write"', "scope", "read write"), + ('Bearer error_description="missing scope=wrong", scope="read write"', "scope", "read write"), ( 'Bearer x_resource_metadata="https://decoy.example.com", ' 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"', "resource_metadata", "https://api.example.com/.well-known/oauth-protected-resource", ), + ( + 'Bearer error_description="missing resource_metadata=https://decoy.example.com", ' + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"', + "resource_metadata", + "https://api.example.com/.well-known/oauth-protected-resource", + ), # Values with special characters ( 'Bearer scope="resource:read resource:write user_profile"', @@ -2056,11 +2063,17 @@ def test_extract_field_from_www_auth_valid_cases( ('Bearer realm="api", error="insufficient_scope"', "scope", "no scope parameter"), ('Bearer realm="api", scope="read write"', "resource_metadata", "no resource_metadata parameter"), ('Bearer custom_scope="leaked"', "scope", "substring param name should not match scope"), + ('Bearer error_description="missing scope=wrong"', "scope", "field-like text in quoted value"), ( 'Bearer x_resource_metadata="https://decoy.example.com"', "resource_metadata", "substring param name should not match resource_metadata", ), + ( + 'Bearer error_description="missing resource_metadata=https://decoy.example.com"', + "resource_metadata", + "field-like text in quoted value", + ), # Malformed field (empty value) ("Bearer scope=", "scope", "malformed scope parameter"), ("Bearer resource_metadata=", "resource_metadata", "malformed resource_metadata parameter"), From 3e3b9e6ec547c5447005e47c30e9c3bf683658eb Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Sun, 19 Jul 2026 03:32:08 -0400 Subject: [PATCH 3/4] Align WWW-Authenticate tests with httpx2 --- tests/client/test_auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 6de3e71e15..9dfc6aed6e 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2099,10 +2099,10 @@ def test_extract_field_from_www_auth_invalid_cases( def test_extract_resource_metadata_from_www_auth_ignores_substring_param_name(self): """Test resource_metadata extraction ignores auth-params with longer names.""" - init_response = httpx.Response( + init_response = httpx2.Response( status_code=401, headers={"WWW-Authenticate": 'Bearer x_resource_metadata="https://decoy.example.com"'}, - request=httpx.Request("GET", "https://api.example.com/test"), + request=httpx2.Request("GET", "https://api.example.com/test"), ) result = extract_resource_metadata_from_www_auth(init_response) From b651c45d6744372a306adb58eff67711de257181 Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Sun, 19 Jul 2026 03:37:02 -0400 Subject: [PATCH 4/4] Cover WWW-Authenticate parser edge cases --- tests/client/test_auth.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 9dfc6aed6e..649f04c4f0 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2005,6 +2005,8 @@ class TestWWWAuthenticate: ), # Multiple parameters with unquoted value ('Bearer realm="api", scope=basic', "scope", "basic"), + ("Bearer scope= read", "scope", "read"), + ('Bearer =bad, scope="read"', "scope", "read"), # Decoy parameter name before the real field ('Bearer error_scope="decoy", scope="read write"', "scope", "read write"), ('Bearer error_description="missing scope=wrong", scope="read write"', "scope", "read write"), @@ -2026,6 +2028,7 @@ class TestWWWAuthenticate: "scope", "resource:read resource:write user_profile", ), + ('Bearer scope="say \\"hi\\""', "scope", 'say "hi"'), ( 'Bearer resource_metadata="https://api.example.com/auth/metadata?version=1"', "resource_metadata", @@ -2076,6 +2079,9 @@ def test_extract_field_from_www_auth_valid_cases( ), # Malformed field (empty value) ("Bearer scope=", "scope", "malformed scope parameter"), + ("Bearer scope= ", "scope", "malformed scope parameter with only whitespace"), + ("Bearer scope=,", "scope", "malformed scope parameter with delimiter"), + ('Bearer scope="unterminated', "scope", "unterminated quoted scope parameter"), ("Bearer resource_metadata=", "resource_metadata", "malformed resource_metadata parameter"), ], )