You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
WWW-Authenticate parsing matches a field name as a substring of another auth-param #3009
Ran into this wiring up an OAuth-protected MCP client. When a 401 WWW-Authenticate challenge carries several auth-params, extract_field_from_www_auth (mcp/client/auth/utils.py) matches the requested field name as a substring of another param's name, so a different param shadows the real value.
The pattern is rf'{field_name}=(?:"([^"]+)"|([^\s,]+))' searched with re.search, with no boundary before field_name.
To reproduce
importhttpxfrommcp.client.auth.utilsimportextract_field_from_www_auth, extract_resource_metadata_from_www_authdefr(h):
returnhttpx.Response(401, headers={"WWW-Authenticate": h},
request=httpx.Request("GET", "https://api.example.com/"))
# A real `scope` is present, but `error_scope` shadows it:print(extract_field_from_www_auth(r('Bearer error_scope="decoy", scope="read write"'), "scope"))
# -> 'decoy' (expected 'read write')# Only a decoy param exists; no real `scope`:print(extract_field_from_www_auth(r('Bearer custom_scope="leaked"'), "scope"))
# -> 'leaked' (expected None)# Same for resource_metadata, which drives discovery URL selection:print(extract_resource_metadata_from_www_auth(r('Bearer x_resource_metadata="https://decoy.example.com"')))
# -> 'https://decoy.example.com' (expected None)
Expected behavior
The field name should match only as a complete auth-param name (at the header start or after a whitespace/comma separator), so scope doesn't match inside error_scope. This matters most for resource_metadata, since the client uses it to pick the protected-resource-metadata discovery URL — reading it from the wrong param points discovery at the wrong place.
Environment
mcpmain (reproduces on v1.x as well)
I have a small fix (anchor the param name to the header start or a separator) plus regression cases added to the existing test_extract_field_from_www_auth_* parametrized tests, and can open a PR if this looks right.
Three open PRs fix this (#3012, #3041, #3050). I ran all three against the same differential probe battery (plus current main as baseline) to help triage — full harness output below. All three fix the reported shadowing, and all three pass tests/client/test_auth.py with zero failures on their branches (137/139/142 passed for #3050/#3041/#3012 — each adds a different number of regression tests). They differ on edge cases:
Bearer SCOPE="files" → scope (RFC 7235 auth-param names are case-insensitive)
❌
❌
❌
❌
Summary of the trade-offs:
fix: match complete www-authenticate auth params #3050 is the minimal change (anchor the existing regex at a ^|[\s,] boundary + re.escape the field name). No behavior change in any probe other than the fixes. It does not handle escaped quotes, but neither does main, so that is a pre-existing limitation rather than a regression.
fix(auth): match complete WWW-Authenticate parameters #3012 is the only one that handles RFC 9110 escaped quotes, but it regresses multi-challenge headers: it treats everything after the first space as auth-params of one challenge, so a Bearer challenge that is not first (Basic …, Bearer … / DPoP …, Bearer …) stops being found. That pattern is real — RFC 9449 servers co-advertise DPoP and Bearer.
Fix substring matches in WWW-Authenticate parsing #3041 scopes extraction to the Bearer challenge, which is arguably the right semantics for the RFC 6750 scope / RFC 9728 resource_metadata callers — but it changes the generic contract of extract_field_from_www_auth (a realm lookup on a non-Bearer challenge now returns None where main returned the value). If that is the intended direction it should be explicit in the docstring/signature rather than implicit.
The param-name case-insensitivity row fails on all four — trivial to add via re.IGNORECASE on the #3050 approach if wanted.
@maxisbey since three competing fixes are open for this, the differential matrix above might save a triage pass — TL;DR: #3050 is the minimal no-regression fix; #3012 adds proper escaped-quote parsing but currently regresses multi-challenge headers (live-server repro on the PR); #3041 works but narrows the helper to Bearer-only semantics. Happy to re-run the harness against any revision.
Activity
refactor: parse WWW-Authenticate with a quote-aware auth-param tokenizer
fix: anchor WWW-Authenticate auth-param parsing at token boundaries (#…
fede-kamel commentedon Jul 10, 2026
Three open PRs fix this (#3012, #3041, #3050). I ran all three against the same differential probe battery (plus current
mainas baseline) to help triage — full harness output below. All three fix the reported shadowing, and all three passtests/client/test_auth.pywith zero failures on their branches (137/139/142 passed for #3050/#3041/#3012 — each adds a different number of regression tests). They differ on edge cases:mainBearer error_scope="admin", scope="files"→ scopeadminBearer x_resource_metadata=…, resource_metadata=…→ resource_metadataBearer as_scope=x, scope=files(unquoted) → scopexBearer error_scope="a",scope="files"(no space after comma) → scopeaBasic realm="r", Bearer scope="files"(two challenges) → scopeNoneDPoP algs="ES256", Bearer scope="files"(RFC 9449 co-advertisement) → scopeNoneNewauth realm="apps"→ realm (non-Bearer scheme, generic use)NoneBearer scope="say \"hi, there"(escaped quote + comma in value) → scopeBearer SCOPE="files"→ scope (RFC 7235 auth-param names are case-insensitive)Summary of the trade-offs:
^|[\s,]boundary +re.escapethe field name). No behavior change in any probe other than the fixes. It does not handle escaped quotes, but neither doesmain, so that is a pre-existing limitation rather than a regression.Bearerchallenge that is not first (Basic …, Bearer …/DPoP …, Bearer …) stops being found. That pattern is real — RFC 9449 servers co-advertiseDPoPandBearer.scope/ RFC 9728resource_metadatacallers — but it changes the generic contract ofextract_field_from_www_auth(arealmlookup on a non-Bearer challenge now returnsNonewheremainreturned the value). If that is the intended direction it should be explicit in the docstring/signature rather than implicit.The param-name case-insensitivity row fails on all four — trivial to add via
re.IGNORECASEon the #3050 approach if wanted.fede-kamel commentedon Jul 10, 2026
@maxisbey since three competing fixes are open for this, the differential matrix above might save a triage pass — TL;DR: #3050 is the minimal no-regression fix; #3012 adds proper escaped-quote parsing but currently regresses multi-challenge headers (live-server repro on the PR); #3041 works but narrows the helper to Bearer-only semantics. Happy to re-run the harness against any revision.