Skip to content

fix(auth): match complete WWW-Authenticate parameters#3012

Open
tarunag10 wants to merge 4 commits into
modelcontextprotocol:mainfrom
tarunag10:codex/www-auth-param-boundary
Open

fix(auth): match complete WWW-Authenticate parameters#3012
tarunag10 wants to merge 4 commits into
modelcontextprotocol:mainfrom
tarunag10:codex/www-auth-param-boundary

Conversation

@tarunag10

Copy link
Copy Markdown

Summary

  • match WWW-Authenticate auth-param names only at parameter boundaries
  • prevent suffix matches such as error_scope satisfying scope
  • add regressions for scope and resource_metadata prefixed parameters

Test plan

  • uv run pytest tests/client/test_auth.py::TestWWWAuthenticate -q
  • uv run ruff check src/mcp/client/auth/utils.py tests/client/test_auth.py
  • uv run pyright src/mcp/client/auth/utils.py tests/client/test_auth.py
  • git diff --check

Related: #2902

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Re-trigger cubic

@tarunag10

Copy link
Copy Markdown
Author

Pushed a follow-up commit (2e2e819) to make the parser quote-aware, not just boundary-aware.

The extractor now splits WWW-Authenticate auth-params on commas only outside quoted strings, then compares auth-param names exactly before returning a value. This preserves the substring-name fix while also avoiding false matches inside quoted values such as realm="api, scope=decoy".

Additional validation added:

  • quoted scope decoy before the real scope
  • quoted-only scope decoy returns None
  • quoted resource_metadata decoy before the real resource_metadata

Validation run locally:

  • uv run pytest tests/client/test_auth.py -k "www_auth" -q — 29 passed
  • uv run ruff format --check src/mcp/client/auth/utils.py tests/client/test_auth.py
  • uv run ruff check src/mcp/client/auth/utils.py tests/client/test_auth.py
  • git diff --check

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/client/auth/utils.py">

<violation number="1" location="src/mcp/client/auth/utils.py:69">
P2: `extract_field_from_www_auth` fails to match fields in subsequent `WWW-Authenticate` challenges because the scheme prefix is kept in the parsed parameter name (e.g. `"Bearer scope"`), so valid headers with multiple challenges are misparsed.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

match = re.search(pattern, www_auth_header)
for param in _iter_www_auth_params(www_auth_header):
name, separator, value = param.partition("=")
if separator != "=" or name.strip() != field_name:

@cubic-dev-ai cubic-dev-ai Bot Jun 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: extract_field_from_www_auth fails to match fields in subsequent WWW-Authenticate challenges because the scheme prefix is kept in the parsed parameter name (e.g. "Bearer scope"), so valid headers with multiple challenges are misparsed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/auth/utils.py, line 69:

<comment>`extract_field_from_www_auth` fails to match fields in subsequent `WWW-Authenticate` challenges because the scheme prefix is kept in the parsed parameter name (e.g. `"Bearer scope"`), so valid headers with multiple challenges are misparsed.</comment>

<file context>
@@ -26,14 +64,16 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No
-    match = re.search(pattern, www_auth_header)
+    for param in _iter_www_auth_params(www_auth_header):
+        name, separator, value = param.partition("=")
+        if separator != "=" or name.strip() != field_name:
+            continue
 
</file context>
Fix with cubic

@tarunag10

Copy link
Copy Markdown
Author

Pushed one more test-only follow-up (c05bc04) after the GitHub matrix exposed a coverage-only failure.

The failing jobs had all tests passing, then failed the repo-wide 100% coverage gate because the new quote-aware splitter had untested escaped-character branches. I added a regression for an escaped quote inside a quoted auth-param value, which exercises that branch while preserving the real scope extraction.

Validation run locally:

  • uv run pytest tests/client/test_auth.py -k "www_auth" -q — 30 passed
  • uv run ruff format --check src/mcp/client/auth/utils.py tests/client/test_auth.py
  • uv run ruff check src/mcp/client/auth/utils.py tests/client/test_auth.py
  • git diff --check

@tarunag10

Copy link
Copy Markdown
Author

Pushed another test-only coverage follow-up (32950d8) for the remaining splitter branches.

Added regressions for:

  • a Bearer challenge with no auth-params
  • empty comma segments between auth-params
  • a trailing comma after the final auth-param

Local validation now covers 33 selected auth tests:

  • uv run pytest tests/client/test_auth.py -k "www_auth" -q — 33 passed
  • uv run ruff format --check src/mcp/client/auth/utils.py tests/client/test_auth.py
  • uv run ruff check src/mcp/client/auth/utils.py tests/client/test_auth.py
  • git diff --check

@fede-kamel fede-kamel left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I differential-tested this against main and the two sibling PRs for #3009 (#3041, #3050) — full matrix in #3009 (comment).

Credit first: this is the only implementation of the three that correctly handles RFC 9110 escaped quotes inside quoted values — Bearer scope="say \"hi, there" parses intact here and truncates at the comma everywhere else, including main.

But I found a regression on multi-challenge headers. _iter_www_auth_params takes everything after the first space as the auth-params of a single challenge, so when the Bearer challenge is not the first one, the field is no longer found:

extract_field_from_www_auth(r('Basic realm="r", Bearer scope="files"'), "scope")
# main -> "files";  this branch -> None

extract_field_from_www_auth(r('DPoP algs="ES256", Bearer scope="files"'), "scope")
# main -> "files";  this branch -> None

The token for the second challenge comes through as Bearer scope="files", whose name (Bearer scope) never equals scope. The DPoP + Bearer co-advertisement is a real pattern (RFC 9449 §7), so this would break scope discovery against DPoP-capable servers.

A possible fix within this design: when a token contains a space before its = (i.e. it looks like Scheme param=…), treat it as the start of a new challenge and strip the scheme prefix before comparing. Alternatively, the escape-aware parsing here could be rebased as a follow-up on top of the minimal boundary fix in #3050, which keeps multi-challenge behavior intact.

@fede-kamel

Copy link
Copy Markdown

Follow-up with end-to-end confirmation of the multi-challenge regression, since my first comment used constructed header strings.

I stood up a raw-socket HTTP server returning 401 with two separate WWW-Authenticate headers (which RFC 9110 permits and reverse proxies commonly produce) and fetched it with real httpx:

server sends:  WWW-Authenticate: Basic realm="legacy"
               WWW-Authenticate: Bearer resource_metadata="https://rs.example/prm"

httpx presents:  'Basic realm="legacy", Bearer resource_metadata="https://rs.example/prm"'

extract_field_from_www_auth(resp, "resource_metadata")
#   main → "https://rs.example/prm"
#   this branch → None

Two implications beyond my earlier comment:

  1. Because httpx joins repeated headers with ", ", the regression triggers even for servers that never put two challenges in one header — separate Basic + Bearer headers are enough.
  2. The affected caller isn't just scope discovery: extract_resource_metadata_from_www_auth is how the client locates the RFC 9728 protected-resource metadata, so a None here breaks the OAuth discovery flow at its first step for any server whose Bearer challenge isn't the first header.

Same live scenario passes on main and on the #3050 approach, so this is specific to the first-space challenge parsing here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants