From 5de906e835354e746f8ca7c47e9f177cd519a92c Mon Sep 17 00:00:00 2001 From: whning0513 <3267069960@qq.com> Date: Wed, 1 Jul 2026 21:02:36 +0800 Subject: [PATCH 1/6] Fix substring matches in WWW-Authenticate parsing --- src/mcp/client/auth/utils.py | 4 ++-- tests/client/test_auth.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index d6b05e066..a46c5968f 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -26,8 +26,8 @@ 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,]+))' + # Match a complete auth-param name at the header start or after a separator. + pattern = rf'(?:^|[\s,]){re.escape(field_name)}=(?:"([^"]+)"|([^\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 1ec38ccf6..b65b709d5 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2016,6 +2016,7 @@ class TestWWWAuthenticate: "resource_metadata", "https://api.example.com/auth/metadata?version=1", ), + ('Bearer error_scope="decoy", scope="read write"', "scope", "read write"), ], ) def test_extract_field_from_www_auth_valid_cases( @@ -2047,6 +2048,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", "field name appears only as a substring"), + ( + 'Bearer x_resource_metadata="https://decoy.example.com"', + "resource_metadata", + "field name appears only as a substring", + ), # Malformed field (empty value) ("Bearer scope=", "scope", "malformed scope parameter"), ("Bearer resource_metadata=", "resource_metadata", "malformed resource_metadata parameter"), From 8a05814321e77d0cfc6951803e67d01b2213f6ca Mon Sep 17 00:00:00 2001 From: whning0513 <3267069960@qq.com> Date: Wed, 1 Jul 2026 21:19:16 +0800 Subject: [PATCH 2/6] Avoid matching auth params inside quoted values --- src/mcp/client/auth/utils.py | 20 +++++++++++++------- tests/client/test_auth.py | 5 +++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index a46c5968f..7d48f9da3 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -26,13 +26,19 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No if not www_auth_header: return None - # Match a complete auth-param name at the header start or after a separator. - pattern = rf'(?:^|[\s,]){re.escape(field_name)}=(?:"([^"]+)"|([^\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) + # Strip the auth scheme (e.g. "Bearer") so parsing only sees auth-params. + _, separator, auth_params = www_auth_header.partition(" ") + if not separator: + auth_params = www_auth_header + + # Match comma-delimited auth-params while respecting quoted values. + pattern = re.compile( + r'(?:^|,\s*)(?P[A-Za-z][A-Za-z0-9_-]*)=(?:"(?P[^"]+)"|(?P[^,\s]+))' + ) + for match in pattern.finditer(auth_params): + if match.group("name") == field_name: + # Return quoted value if present, otherwise unquoted value + return match.group("quoted") or match.group("unquoted") return None diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index b65b709d5..73da66d34 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2017,6 +2017,11 @@ class TestWWWAuthenticate: "https://api.example.com/auth/metadata?version=1", ), ('Bearer error_scope="decoy", scope="read write"', "scope", "read write"), + ( + 'Bearer error_description="missing scope=write permission", scope="read write"', + "scope", + "read write", + ), ], ) def test_extract_field_from_www_auth_valid_cases( From d725212524d20bbca3f70a0a67281432ce7b13c1 Mon Sep 17 00:00:00 2001 From: whning0513 <3267069960@qq.com> Date: Thu, 2 Jul 2026 15:34:26 +0800 Subject: [PATCH 3/6] Handle Bearer auth params in multi-challenge headers --- src/mcp/client/auth/utils.py | 57 +++++++++++++++++++++++++++++++----- tests/client/test_auth.py | 5 ++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 7d48f9da3..6de2edb8d 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -16,6 +16,52 @@ from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +def _split_www_authenticate_segments(header_value: str) -> list[str]: + """Split a WWW-Authenticate header on top-level commas.""" + segments: list[str] = [] + current: list[str] = [] + in_quotes = False + + for char in header_value: + if char == '"': + in_quotes = not in_quotes + if char == "," and not in_quotes: + segment = "".join(current).strip() + if segment: + segments.append(segment) + current = [] + continue + current.append(char) + + tail = "".join(current).strip() + if tail: + segments.append(tail) + return segments + + +def _extract_bearer_auth_params(www_auth_header: str) -> str | None: + """Return the auth-param portion of the first Bearer challenge.""" + segments = _split_www_authenticate_segments(www_auth_header) + collecting = False + auth_params: list[str] = [] + + for segment in segments: + scheme, separator, remainder = segment.partition(" ") + if scheme.lower() == "bearer" and separator: + collecting = True + auth_params = [remainder.strip()] + continue + + if collecting: + if separator and "=" not in scheme: + break + auth_params.append(segment) + + if not auth_params: + return None + return ", ".join(part for part in auth_params if part) + + def extract_field_from_www_auth(response: Response, field_name: str) -> str | None: """Extract field from WWW-Authenticate header. @@ -26,15 +72,12 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No if not www_auth_header: return None - # Strip the auth scheme (e.g. "Bearer") so parsing only sees auth-params. - _, separator, auth_params = www_auth_header.partition(" ") - if not separator: - auth_params = www_auth_header + auth_params = _extract_bearer_auth_params(www_auth_header) + if auth_params is None: + return None # Match comma-delimited auth-params while respecting quoted values. - pattern = re.compile( - r'(?:^|,\s*)(?P[A-Za-z][A-Za-z0-9_-]*)=(?:"(?P[^"]+)"|(?P[^,\s]+))' - ) + pattern = re.compile(r'(?:^|,\s*)(?P[A-Za-z][A-Za-z0-9_-]*)=(?:"(?P[^"]+)"|(?P[^,\s]+))') for match in pattern.finditer(auth_params): if match.group("name") == field_name: # Return quoted value if present, otherwise unquoted value diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 73da66d34..f42d953bb 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2022,6 +2022,11 @@ class TestWWWAuthenticate: "scope", "read write", ), + ( + 'Basic realm="legacy", Bearer scope="read write", error="insufficient_scope"', + "scope", + "read write", + ), ], ) def test_extract_field_from_www_auth_valid_cases( From 3e99ffe8cfd4afb0870fa194ef0f8a4d3f130422 Mon Sep 17 00:00:00 2001 From: whning0513 <3267069960@qq.com> Date: Tue, 7 Jul 2026 02:12:00 +0800 Subject: [PATCH 4/6] Handle more Bearer auth-param edge cases --- src/mcp/client/auth/utils.py | 8 ++++++-- tests/client/test_auth.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 6de2edb8d..36e019abe 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -48,12 +48,14 @@ def _extract_bearer_auth_params(www_auth_header: str) -> str | None: for segment in segments: scheme, separator, remainder = segment.partition(" ") if scheme.lower() == "bearer" and separator: + if collecting: + break collecting = True auth_params = [remainder.strip()] continue if collecting: - if separator and "=" not in scheme: + if separator and "=" not in scheme and not remainder.lstrip().startswith("="): break auth_params.append(segment) @@ -77,7 +79,9 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No return None # Match comma-delimited auth-params while respecting quoted values. - pattern = re.compile(r'(?:^|,\s*)(?P[A-Za-z][A-Za-z0-9_-]*)=(?:"(?P[^"]+)"|(?P[^,\s]+))') + pattern = re.compile( + r'(?:^|,\s*)(?P[A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"(?P[^"]*)"|(?P[^,\s]+))' + ) for match in pattern.finditer(auth_params): if match.group("name") == field_name: # Return quoted value if present, otherwise unquoted value diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index f42d953bb..e43f2daf8 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -181,6 +181,39 @@ def test_pkce_uniqueness(self): assert pkce1.code_challenge != pkce2.code_challenge +class TestExtractFieldFromWwwAuth: + def test_uses_first_bearer_challenge_when_multiple_are_present(self): + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": ( + 'Basic realm="legacy", Bearer scope="read", error="insufficient_scope", Bearer scope="write"' + ) + }, + request=httpx.Request("GET", "https://example.com"), + ) + + assert extract_scope_from_www_auth(response) == "read" + + def test_supports_optional_whitespace_around_equals(self): + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": ( + 'Bearer error="insufficient_scope", scope = "read write", ' + 'resource_metadata = "https://example.com/.well-known/oauth-protected-resource"' + ) + }, + request=httpx.Request("GET", "https://example.com"), + ) + + assert extract_scope_from_www_auth(response) == "read write" + assert ( + extract_resource_metadata_from_www_auth(response) + == "https://example.com/.well-known/oauth-protected-resource" + ) + + class TestOAuthContext: """Test OAuth context functionality.""" From d4cfbc5b1beee288adf5ebd6641371a0c38a2234 Mon Sep 17 00:00:00 2001 From: whning Date: Mon, 20 Jul 2026 03:13:31 +0800 Subject: [PATCH 5/6] Address WWW-Authenticate parser review feedback --- src/mcp/client/auth/utils.py | 57 +++++++++++++----- tests/client/test_auth.py | 108 ++++++++++++++++++++++++++--------- 2 files changed, 122 insertions(+), 43 deletions(-) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 36e019abe..7d4e1881a 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -21,9 +21,14 @@ def _split_www_authenticate_segments(header_value: str) -> list[str]: segments: list[str] = [] current: list[str] = [] in_quotes = False + escaped = False for char in header_value: - if char == '"': + if escaped: + escaped = False + elif char == "\\" and in_quotes: + escaped = True + elif char == '"': in_quotes = not in_quotes if char == "," and not in_quotes: segment = "".join(current).strip() @@ -64,6 +69,35 @@ def _extract_bearer_auth_params(www_auth_header: str) -> str | None: return ", ".join(part for part in auth_params if part) +_AUTH_PARAM_PATTERN = re.compile( + r"(?:^|,\s*)(?P[A-Za-z][A-Za-z0-9_-]*)\s*=\s*" + r'(?:"(?P(?:\\.|[^"\\])*)"|(?P[^,\s]+))' +) + + +def _extract_auth_param(auth_params: str, field_name: str) -> str | None: + """Extract an auth-param from a comma-delimited parameter string.""" + for match in _AUTH_PARAM_PATTERN.finditer(auth_params): + if match.group("name") != field_name: + continue + quoted = match.group("quoted") + value = quoted if quoted is not None else match.group("unquoted") + if not value: + return None + return re.sub(r"\\(.)", r"\1", value) if quoted is not None else value + return None + + +def _extract_field_from_bearer_challenge(response: Response, field_name: str) -> str | None: + """Extract an auth-param from the first Bearer challenge.""" + www_auth_header = response.headers.get("WWW-Authenticate") + if not www_auth_header: + return None + + auth_params = _extract_bearer_auth_params(www_auth_header) + return _extract_auth_param(auth_params, field_name) if auth_params is not None else None + + def extract_field_from_www_auth(response: Response, field_name: str) -> str | None: """Extract field from WWW-Authenticate header. @@ -74,18 +108,11 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No if not www_auth_header: return None - auth_params = _extract_bearer_auth_params(www_auth_header) - if auth_params is None: - return None - - # Match comma-delimited auth-params while respecting quoted values. - pattern = re.compile( - r'(?:^|,\s*)(?P[A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"(?P[^"]*)"|(?P[^,\s]+))' - ) - for match in pattern.finditer(auth_params): - if match.group("name") == field_name: - # Return quoted value if present, otherwise unquoted value - return match.group("quoted") or match.group("unquoted") + for segment in _split_www_authenticate_segments(www_auth_header): + scheme, separator, remainder = segment.partition(" ") + auth_params = remainder.strip() if separator and "=" not in scheme else segment + if value := _extract_auth_param(auth_params, field_name): + return value return None @@ -96,7 +123,7 @@ def extract_scope_from_www_auth(response: Response) -> str | None: Returns: Scope string if found in WWW-Authenticate header, None otherwise """ - return extract_field_from_www_auth(response, "scope") + return _extract_field_from_bearer_challenge(response, "scope") 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: if not response or response.status_code != 401: return None # pragma: no cover - return extract_field_from_www_auth(response, "resource_metadata") + return _extract_field_from_bearer_challenge(response, "resource_metadata") def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]: diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index e43f2daf8..b27378218 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -181,37 +181,89 @@ def test_pkce_uniqueness(self): assert pkce1.code_challenge != pkce2.code_challenge -class TestExtractFieldFromWwwAuth: - def test_uses_first_bearer_challenge_when_multiple_are_present(self): - response = httpx.Response( - 401, - headers={ - "WWW-Authenticate": ( - 'Basic realm="legacy", Bearer scope="read", error="insufficient_scope", Bearer scope="write"' - ) - }, - request=httpx.Request("GET", "https://example.com"), - ) +def test_scope_uses_first_bearer_challenge_when_multiple_are_present(): + """RFC 6750 fields come from the first Bearer challenge, not another scheme or later challenge.""" + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": ( + 'Basic scope="legacy", Bearer scope="read", error="insufficient_scope", Bearer scope="write"' + ) + }, + request=httpx.Request("GET", "https://example.com"), + ) - assert extract_scope_from_www_auth(response) == "read" + assert extract_scope_from_www_auth(response) == "read" - def test_supports_optional_whitespace_around_equals(self): - response = httpx.Response( - 401, - headers={ - "WWW-Authenticate": ( - 'Bearer error="insufficient_scope", scope = "read write", ' - 'resource_metadata = "https://example.com/.well-known/oauth-protected-resource"' - ) - }, - request=httpx.Request("GET", "https://example.com"), - ) - assert extract_scope_from_www_auth(response) == "read write" - assert ( - extract_resource_metadata_from_www_auth(response) - == "https://example.com/.well-known/oauth-protected-resource" - ) +def test_bearer_fields_support_optional_whitespace_around_equals(): + """Bearer auth-params allow optional whitespace around the equals sign.""" + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": ( + 'Bearer error="insufficient_scope", scope = "read write", ' + 'resource_metadata = "https://example.com/.well-known/oauth-protected-resource"' + ) + }, + request=httpx.Request("GET", "https://example.com"), + ) + + assert extract_scope_from_www_auth(response) == "read write" + assert ( + extract_resource_metadata_from_www_auth(response) == "https://example.com/.well-known/oauth-protected-resource" + ) + + +def test_generic_field_lookup_preserves_non_bearer_challenges(): + """The generic helper keeps its pre-existing ability to read fields from other auth schemes.""" + response = httpx.Response( + 401, + headers={"WWW-Authenticate": 'Newauth realm="apps"'}, + request=httpx.Request("GET", "https://example.com"), + ) + + assert extract_field_from_www_auth(response, "realm") == "apps" + assert extract_scope_from_www_auth(response) is None + + +def test_quoted_auth_params_handle_escaped_quotes_and_commas(): + """RFC quoted-pairs do not terminate a quoted value or split its embedded comma.""" + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": ( + 'Newauth realm="apps \\"beta\\", east", ' + 'Bearer error_description="try \\"again\\", later", scope="read write"' + ) + }, + request=httpx.Request("GET", "https://example.com"), + ) + + assert extract_field_from_www_auth(response, "realm") == 'apps "beta", east' + assert extract_scope_from_www_auth(response) == "read write" + + +def test_bearer_parser_ignores_empty_segments_and_stops_at_next_challenge(): + """The first Bearer challenge ends when another authentication scheme begins.""" + response = httpx.Response( + 401, + headers={"WWW-Authenticate": ', Bearer scope="read", Basic realm="legacy",'}, + request=httpx.Request("GET", "https://example.com"), + ) + + assert extract_scope_from_www_auth(response) == "read" + + +def test_empty_quoted_auth_param_returns_none(): + """An empty quoted auth-param has the same missing-value result as an unquoted empty parameter.""" + response = httpx.Response( + 401, + headers={"WWW-Authenticate": 'Bearer scope=""'}, + request=httpx.Request("GET", "https://example.com"), + ) + + assert extract_scope_from_www_auth(response) is None class TestOAuthContext: From 397896e4ebddabec951e15c7293de640acf88d5a Mon Sep 17 00:00:00 2001 From: whn <142425816+Whning0513@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:37:52 +0800 Subject: [PATCH 6/6] Use current HTTP client name in auth tests --- tests/client/test_auth.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index b7881f367..954004db3 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -183,14 +183,14 @@ def test_pkce_uniqueness(self): def test_scope_uses_first_bearer_challenge_when_multiple_are_present(): """RFC 6750 fields come from the first Bearer challenge, not another scheme or later challenge.""" - response = httpx.Response( + response = httpx2.Response( 401, headers={ "WWW-Authenticate": ( 'Basic scope="legacy", Bearer scope="read", error="insufficient_scope", Bearer scope="write"' ) }, - request=httpx.Request("GET", "https://example.com"), + request=httpx2.Request("GET", "https://example.com"), ) assert extract_scope_from_www_auth(response) == "read" @@ -198,7 +198,7 @@ def test_scope_uses_first_bearer_challenge_when_multiple_are_present(): def test_bearer_fields_support_optional_whitespace_around_equals(): """Bearer auth-params allow optional whitespace around the equals sign.""" - response = httpx.Response( + response = httpx2.Response( 401, headers={ "WWW-Authenticate": ( @@ -206,7 +206,7 @@ def test_bearer_fields_support_optional_whitespace_around_equals(): 'resource_metadata = "https://example.com/.well-known/oauth-protected-resource"' ) }, - request=httpx.Request("GET", "https://example.com"), + request=httpx2.Request("GET", "https://example.com"), ) assert extract_scope_from_www_auth(response) == "read write" @@ -217,10 +217,10 @@ def test_bearer_fields_support_optional_whitespace_around_equals(): def test_generic_field_lookup_preserves_non_bearer_challenges(): """The generic helper keeps its pre-existing ability to read fields from other auth schemes.""" - response = httpx.Response( + response = httpx2.Response( 401, headers={"WWW-Authenticate": 'Newauth realm="apps"'}, - request=httpx.Request("GET", "https://example.com"), + request=httpx2.Request("GET", "https://example.com"), ) assert extract_field_from_www_auth(response, "realm") == "apps" @@ -229,7 +229,7 @@ def test_generic_field_lookup_preserves_non_bearer_challenges(): def test_quoted_auth_params_handle_escaped_quotes_and_commas(): """RFC quoted-pairs do not terminate a quoted value or split its embedded comma.""" - response = httpx.Response( + response = httpx2.Response( 401, headers={ "WWW-Authenticate": ( @@ -237,7 +237,7 @@ def test_quoted_auth_params_handle_escaped_quotes_and_commas(): 'Bearer error_description="try \\"again\\", later", scope="read write"' ) }, - request=httpx.Request("GET", "https://example.com"), + request=httpx2.Request("GET", "https://example.com"), ) assert extract_field_from_www_auth(response, "realm") == 'apps "beta", east' @@ -246,10 +246,10 @@ def test_quoted_auth_params_handle_escaped_quotes_and_commas(): def test_bearer_parser_ignores_empty_segments_and_stops_at_next_challenge(): """The first Bearer challenge ends when another authentication scheme begins.""" - response = httpx.Response( + response = httpx2.Response( 401, headers={"WWW-Authenticate": ', Bearer scope="read", Basic realm="legacy",'}, - request=httpx.Request("GET", "https://example.com"), + request=httpx2.Request("GET", "https://example.com"), ) assert extract_scope_from_www_auth(response) == "read" @@ -257,10 +257,10 @@ def test_bearer_parser_ignores_empty_segments_and_stops_at_next_challenge(): def test_empty_quoted_auth_param_returns_none(): """An empty quoted auth-param has the same missing-value result as an unquoted empty parameter.""" - response = httpx.Response( + response = httpx2.Response( 401, headers={"WWW-Authenticate": 'Bearer scope=""'}, - request=httpx.Request("GET", "https://example.com"), + request=httpx2.Request("GET", "https://example.com"), ) assert extract_scope_from_www_auth(response) is None