Skip to content

Commit 6ba4a76

Browse files
committed
Align OAuth client with spec on PKCE verification and scope selection
Two changes to how the OAuth client uses discovered authorization-server metadata, both required by the MCP authorization specification. Verify PKCE support before the authorization-code grant. The spec's Authorization Code Protection section requires clients to verify PKCE support from the authorization server's metadata and to refuse to proceed when code_challenge_methods_supported is absent. The new validate_pkce_support() also refuses a method list that omits S256, since that is the only method this client sends. The check sits at the top of _perform_authorization_code_grant, so it covers both the initial 401 flow and the 403 insufficient_scope step-up, while grants that never issue an authorization code (client credentials, private key JWT) are unaffected. When no metadata document was discovered at all the flow proceeds as before: absence of a document is not evidence of non-support. Stop reading scopes_supported from authorization-server metadata when selecting a scope. The spec's scope-selection chain is the WWW-Authenticate scope parameter, then the protected-resource metadata's scopes_supported, otherwise omit the scope parameter. The SDK inserted an extra fallback to the authorization server's scopes_supported, which over-requests (an authorization server may serve many resource servers, so its list is a superset of any one resource's) and causes access_denied failures against servers that reject unknown scopes. With the fallback removed, clients that relied on it should pass an explicit scope on their OAuthClientMetadata. Closes #1307
1 parent 5dfcaea commit 6ba4a76

7 files changed

Lines changed: 266 additions & 41 deletions

File tree

docs/advanced/oauth-clients.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ The first time `Client` sends a request, the server answers `401`. The provider
7878

7979
1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata.
8080
2. **Registration.** Nothing in storage? It registers you dynamically with your `OAuthClientMetadata` and stores the result.
81-
3. **Authorization.** It generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code.
81+
3. **Authorization.** It checks that the discovered authorization-server metadata advertises `S256` PKCE support (and stops with `OAuthFlowError` if it does not), generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code.
8282
4. **Exchange.** It trades the code for an `OAuthToken`, stores it, and replays your original request with `Authorization: Bearer ...`.
8383

8484
After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again.

docs/migration.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,18 @@ async def callback_handler() -> AuthorizationCodeResult:
121121

122122
Forward the `iss` query parameter from the redirect so the validation can run: omitting it makes the flow fail with `OAuthFlowError` against servers that advertise `authorization_response_iss_parameter_supported`, and silently skips the check for servers that send `iss` without advertising it.
123123

124+
### OAuth client refuses to authorize when AS metadata does not advertise S256 PKCE
125+
126+
The OAuth client now verifies PKCE support before starting the authorization-code grant, as the MCP authorization specification's Authorization Code Protection section requires. When an authorization-server metadata document was discovered and its `code_challenge_methods_supported` is absent (which the spec defines as "the authorization server does not support PKCE") or does not list `S256` (the only method the SDK sends), the flow raises `OAuthFlowError` instead of redirecting to the authorization endpoint. The symptom is `OAuthFlowError: Authorization server metadata does not include code_challenge_methods_supported; PKCE support cannot be verified`. Previously the SDK never inspected the field and proceeded with an S256 challenge regardless.
127+
128+
When no authorization-server metadata document could be discovered at all, the flow proceeds as before — absence of a document is not evidence of non-support. Grants that never issue an authorization code (`ClientCredentialsOAuthProvider`, `PrivateKeyJWTOAuthProvider`) are unaffected. There is no SDK-side opt-out: an authorization server that supports S256 but omits the field from its published metadata needs that metadata fixed (RFC 8414 §2 defines `code_challenge_methods_supported`).
129+
130+
### OAuth client no longer reads `scopes_supported` from AS metadata to choose a scope
131+
132+
The specification's scope-selection chain is two steps: the `scope` parameter from the `WWW-Authenticate` challenge, then `scopes_supported` from the Protected Resource Metadata document, *otherwise the `scope` parameter is omitted*. The SDK inserted an extra fallback between those two steps — the **authorization-server** metadata's `scopes_supported` — which over-requests (an authorization server may serve many resource servers, so its list is a superset of any one resource's) and caused real `access_denied` failures ([#1307](https://github.com/modelcontextprotocol/python-sdk/issues/1307)). That fallback is removed: when neither the challenge nor the PRM names scopes, the client now omits the `scope` parameter and lets the authorization server apply its defaults.
133+
134+
This also affects the SEP-2207 `offline_access` augmentation, which only fires once a base scope was selected: if the authorization server's `scopes_supported` was your only scope source, the client now sends no `scope` at all (not even `offline_access`) and the authorization server's defaults decide whether a refresh token is issued. In either case, if you relied on the removed fallback, pass an explicit `scope` on the `OAuthClientMetadata` you give to `OAuthClientProvider`.
135+
124136
### `get_session_id` callback removed from `streamable_http_client`
125137

126138
The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple.

src/mcp/client/auth/oauth2.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
2323
from mcp.client.auth.utils import (
24+
CODE_CHALLENGE_METHOD,
2425
build_oauth_authorization_server_metadata_discovery_urls,
2526
build_protected_resource_metadata_discovery_urls,
2627
create_client_info_from_metadata_url,
@@ -40,6 +41,7 @@
4041
union_scopes,
4142
validate_authorization_response_iss,
4243
validate_metadata_issuer,
44+
validate_pkce_support,
4345
)
4446
from mcp.shared.auth import (
4547
AuthorizationCodeResult,
@@ -323,6 +325,12 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
323325
if not self.context.callback_handler:
324326
raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover
325327

328+
# Authorization Code Protection: a discovered metadata document that does not advertise
329+
# S256 PKCE support must stop the flow before any authorize redirect is built. When no
330+
# document was discovered at all there is nothing to verify against, so the flow proceeds.
331+
if self.context.oauth_metadata is not None:
332+
validate_pkce_support(self.context.oauth_metadata)
333+
326334
if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint:
327335
auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint)
328336
else:
@@ -342,7 +350,7 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
342350
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
343351
"state": state,
344352
"code_challenge": pkce_params.code_challenge,
345-
"code_challenge_method": "S256",
353+
"code_challenge_method": CODE_CHALLENGE_METHOD,
346354
}
347355

348356
# Only include resource param if conditions are met

src/mcp/client/auth/utils.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
from typing import Final
23
from urllib.parse import urljoin, urlparse
34

45
from httpx import Request, Response
@@ -15,6 +16,10 @@
1516
)
1617
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
1718

19+
# The only PKCE code challenge method this client sends (OAuth 2.1 section 4.1.1 mandates S256).
20+
# `validate_pkce_support` checks the authorization server's metadata against the same name.
21+
CODE_CHALLENGE_METHOD: Final = "S256"
22+
1823

1924
def extract_field_from_www_auth(response: Response, field_name: str) -> str | None:
2025
"""Extract field from WWW-Authenticate header.
@@ -107,14 +112,11 @@ def get_client_metadata_scopes(
107112
# MCP spec scope selection priority:
108113
# 1. WWW-Authenticate header scope
109114
# 2. PRM scopes_supported
110-
# 3. AS scopes_supported (SDK fallback)
111-
# 4. Omit scope parameter
115+
# 3. Omit scope parameter
112116
if www_authenticate_scope is not None:
113117
selected_scope = www_authenticate_scope
114118
elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None:
115119
selected_scope = " ".join(protected_resource_metadata.scopes_supported)
116-
elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None:
117-
selected_scope = " ".join(authorization_server_metadata.scopes_supported)
118120

119121
# SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens
120122
if (
@@ -272,6 +274,30 @@ def validate_metadata_issuer(oauth_metadata: OAuthMetadata, expected_issuer: str
272274
)
273275

274276

277+
def validate_pkce_support(oauth_metadata: OAuthMetadata) -> None:
278+
"""Verify that authorization-server metadata advertises support for S256 PKCE.
279+
280+
Per the MCP authorization specification's Authorization Code Protection requirements, a
281+
client must verify PKCE support from the authorization server's metadata before proceeding
282+
with authorization: an absent `code_challenge_methods_supported` means the server does not
283+
support PKCE. The SDK only ever sends the mandatory `S256` method, so a method list that
284+
omits `S256` is equally unusable.
285+
286+
Raises:
287+
OAuthFlowError: If `code_challenge_methods_supported` is absent or does not list `S256`.
288+
"""
289+
methods = oauth_metadata.code_challenge_methods_supported
290+
if methods is None:
291+
raise OAuthFlowError(
292+
"Authorization server metadata does not include code_challenge_methods_supported; "
293+
"PKCE support cannot be verified"
294+
)
295+
if CODE_CHALLENGE_METHOD not in methods:
296+
raise OAuthFlowError(
297+
f"Authorization server does not support the {CODE_CHALLENGE_METHOD} PKCE code challenge method: {methods}"
298+
)
299+
300+
275301
def create_oauth_metadata_request(url: str) -> Request:
276302
return Request("GET", url, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_PROTOCOL_VERSION})
277303

tests/client/test_auth.py

Lines changed: 131 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
union_scopes,
3131
validate_authorization_response_iss,
3232
validate_metadata_issuer,
33+
validate_pkce_support,
3334
)
3435
from mcp.server.auth.routes import build_metadata
3536
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
@@ -2447,7 +2448,11 @@ def test_offline_access_not_duplicated_when_already_present(self):
24472448
assert scopes == "read offline_access write"
24482449

24492450
def test_offline_access_not_added_when_no_scopes_selected(self):
2450-
"""offline_access is not added when no base scopes are available (None)."""
2451+
"""offline_access is not appended when the challenge and PRM yield no base scope.
2452+
2453+
The AS metadata advertising offline_access is not a scope source, so there is no
2454+
selected scope to augment and the result stays None.
2455+
"""
24512456
asm = self._make_as_metadata(scopes_supported=["offline_access"])
24522457

24532458
scopes = get_client_metadata_scopes(
@@ -2456,9 +2461,7 @@ def test_offline_access_not_added_when_no_scopes_selected(self):
24562461
authorization_server_metadata=asm,
24572462
client_grant_types=["authorization_code", "refresh_token"],
24582463
)
2459-
# When AS scopes are the only source and include offline_access,
2460-
# the base scope is "offline_access" and no duplication happens
2461-
assert scopes == "offline_access"
2464+
assert scopes is None
24622465

24632466
def test_offline_access_not_added_when_as_scopes_supported_is_none(self):
24642467
"""offline_access is not added when AS scopes_supported is None."""
@@ -2571,6 +2574,7 @@ async def callback_handler() -> AuthorizationCodeResult:
25712574
b'{"issuer": "https://auth.example.com",'
25722575
b' "authorization_endpoint": "https://auth.example.com/authorize",'
25732576
b' "token_endpoint": "https://auth.example.com/token",'
2577+
b' "code_challenge_methods_supported": ["S256"],'
25742578
b' "scopes_supported": ["read", "write", "offline_access"]}'
25752579
),
25762580
request=oauth_request,
@@ -2679,6 +2683,7 @@ async def callback_handler() -> AuthorizationCodeResult:
26792683
b'{"issuer": "https://auth.example.com",'
26802684
b' "authorization_endpoint": "https://auth.example.com/authorize",'
26812685
b' "token_endpoint": "https://auth.example.com/token",'
2686+
b' "code_challenge_methods_supported": ["S256"],'
26822687
b' "scopes_supported": ["read", "write"]}'
26832688
),
26842689
request=oauth_request,
@@ -2780,6 +2785,48 @@ def test_validate_metadata_issuer_rejects_mismatch():
27802785
validate_metadata_issuer(_issuer_metadata(issuer="https://attacker.example.com"), _ISSUER)
27812786

27822787

2788+
def test_as_metadata_scopes_supported_is_never_used_as_a_scope_source():
2789+
"""When neither the challenge nor the PRM names scopes, scope is omitted even though the AS advertises some.
2790+
2791+
The spec's scope-selection chain is WWW-Authenticate scope, then PRM scopes_supported, then
2792+
omit; authorization-server metadata is not a step in it.
2793+
"""
2794+
asm = OAuthMetadata(
2795+
issuer=AnyHttpUrl("https://auth.example.com"),
2796+
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
2797+
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
2798+
scopes_supported=["as-only:read", "as-only:write"],
2799+
)
2800+
assert get_client_metadata_scopes(None, None, asm) is None
2801+
2802+
2803+
def test_pkce_validation_passes_when_as_metadata_lists_s256():
2804+
"""Metadata whose code_challenge_methods_supported includes S256 is accepted, whatever else it lists."""
2805+
validate_pkce_support(_issuer_metadata().model_copy(update={"code_challenge_methods_supported": ["plain", "S256"]}))
2806+
2807+
2808+
def test_pkce_validation_refuses_when_code_challenge_methods_supported_is_absent():
2809+
"""An AS metadata document without code_challenge_methods_supported is refused: PKCE support is unverified."""
2810+
metadata = _issuer_metadata()
2811+
assert metadata.code_challenge_methods_supported is None
2812+
with pytest.raises(OAuthFlowError) as exc_info:
2813+
validate_pkce_support(metadata)
2814+
assert str(exc_info.value) == snapshot(
2815+
"Authorization server metadata does not include code_challenge_methods_supported; "
2816+
"PKCE support cannot be verified"
2817+
)
2818+
2819+
2820+
def test_pkce_validation_refuses_when_s256_is_not_among_the_advertised_methods():
2821+
"""A code_challenge_methods_supported list without S256 is refused: the SDK only sends S256."""
2822+
metadata = _issuer_metadata().model_copy(update={"code_challenge_methods_supported": ["plain"]})
2823+
with pytest.raises(OAuthFlowError) as exc_info:
2824+
validate_pkce_support(metadata)
2825+
assert str(exc_info.value) == snapshot(
2826+
"Authorization server does not support the S256 PKCE code challenge method: ['plain']"
2827+
)
2828+
2829+
27832830
@pytest.mark.parametrize(
27842831
("previous", "new", "expected"),
27852832
[
@@ -2980,7 +3027,8 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed(
29803027
content=(
29813028
b'{"issuer": "https://new-as.example.com", '
29823029
b'"authorization_endpoint": "https://new-as.example.com/authorize", '
2983-
b'"token_endpoint": "https://new-as.example.com/token"}'
3030+
b'"token_endpoint": "https://new-as.example.com/token", '
3031+
b'"code_challenge_methods_supported": ["S256"]}'
29843032
),
29853033
)
29863034
],
@@ -3127,7 +3175,8 @@ async def echo_callback() -> AuthorizationCodeResult:
31273175
content=(
31283176
b'{"issuer": "https://api.example.com", '
31293177
b'"authorization_endpoint": "https://api.example.com/authorize", '
3130-
b'"token_endpoint": "https://api.example.com/token"}'
3178+
b'"token_endpoint": "https://api.example.com/token", '
3179+
b'"code_challenge_methods_supported": ["S256"]}'
31313180
),
31323181
request=asm_req,
31333182
)
@@ -3158,3 +3207,79 @@ async def echo_callback() -> AuthorizationCodeResult:
31583207
await auth_flow.asend(httpx.Response(200, request=final_req))
31593208
except StopAsyncIteration:
31603209
pass
3210+
3211+
3212+
@pytest.mark.anyio
3213+
async def test_pkce_is_still_sent_when_no_authorization_server_metadata_document_is_discovered(
3214+
oauth_provider: OAuthClientProvider,
3215+
):
3216+
"""With no discoverable AS metadata, the client still proceeds and sends an S256 PKCE challenge.
3217+
3218+
The refuse-if-unsupported gate is conditioned on a discovered authorization-server metadata
3219+
*document*: failing to obtain one is not evidence of non-support, and the SDK deliberately
3220+
keeps a legacy no-metadata fallback path (the `client-auth:prm-discovery:no-prm-fallback`
3221+
requirement), so the flow falls back to the resource origin's `/authorize` with PKCE intact.
3222+
This has to drive the httpx `async_auth_flow` generator directly: the in-process interaction
3223+
harness cannot express a server with no metadata endpoint, because its real authorize route
3224+
always returns an RFC 9207 `iss` that the client rejects when it has no metadata issuer to
3225+
compare against.
3226+
3227+
Steps:
3228+
1. 401 with no challenge -> path-based then root PRM discovery, both 404.
3229+
2. One root AS-metadata probe (the only URL built when no AS is known), 404 ->
3230+
`oauth_metadata` stays None.
3231+
3. DCR against the resource origin's `/register` fallback.
3232+
4. The authorize redirect is built and carries `code_challenge_method=S256`.
3233+
"""
3234+
oauth_provider.context.current_tokens = None
3235+
oauth_provider.context.token_expiry_time = None
3236+
oauth_provider._initialized = True
3237+
3238+
captured_url: str | None = None
3239+
3240+
async def capture_redirect(url: str) -> None:
3241+
nonlocal captured_url
3242+
captured_url = url
3243+
3244+
async def echo_callback() -> AuthorizationCodeResult:
3245+
assert captured_url is not None
3246+
params = parse_qs(urlparse(captured_url).query)
3247+
return AuthorizationCodeResult(code="auth_code", state=params["state"][0])
3248+
3249+
oauth_provider.context.redirect_handler = capture_redirect
3250+
oauth_provider.context.callback_handler = echo_callback
3251+
3252+
auth_flow = oauth_provider.async_auth_flow(httpx.Request("GET", "https://api.example.com/v1/mcp"))
3253+
request = await auth_flow.__anext__()
3254+
3255+
prm_req = await auth_flow.asend(httpx.Response(401, request=request))
3256+
assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
3257+
prm_req = await auth_flow.asend(httpx.Response(404, request=prm_req))
3258+
assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource"
3259+
3260+
asm_req = await auth_flow.asend(httpx.Response(404, request=prm_req))
3261+
assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server"
3262+
dcr_req = await auth_flow.asend(httpx.Response(404, request=asm_req))
3263+
assert oauth_provider.context.oauth_metadata is None
3264+
assert str(dcr_req.url) == "https://api.example.com/register"
3265+
3266+
token_req = await auth_flow.asend(
3267+
httpx.Response(
3268+
201,
3269+
json={"client_id": "registered", "redirect_uris": ["http://localhost:3030/callback"]},
3270+
request=dcr_req,
3271+
)
3272+
)
3273+
3274+
assert captured_url is not None
3275+
params = parse_qs(urlparse(captured_url).query)
3276+
assert params["code_challenge_method"] == ["S256"]
3277+
assert params["code_challenge"] != [""]
3278+
3279+
final_req = await auth_flow.asend(
3280+
httpx.Response(200, json={"access_token": "t", "token_type": "Bearer", "expires_in": 3600}, request=token_req)
3281+
)
3282+
try:
3283+
await auth_flow.asend(httpx.Response(200, request=final_req))
3284+
except StopAsyncIteration:
3285+
pass

0 commit comments

Comments
 (0)