Skip to content

Commit 496284d

Browse files
committed
fix: reapply #2779 onto current main
1 parent a4f4ccd commit 496284d

2 files changed

Lines changed: 293 additions & 310 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 59 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""OAuth2 Authentication implementation for httpx2.
1+
"""OAuth2 Authentication implementation for HTTPX.
22
33
Implements authorization code flow with PKCE and automatic token refresh.
44
"""
@@ -9,17 +9,17 @@
99
import secrets
1010
import string
1111
import time
12-
from collections.abc import AsyncGenerator, Awaitable, Callable
12+
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
1313
from dataclasses import dataclass, field
14-
from typing import Any, Protocol, get_args
15-
from urllib.parse import quote, urlencode, urljoin, urlparse
14+
from typing import Any, Protocol
15+
from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlparse, urlunparse
1616

1717
import anyio
18-
import httpx2
18+
import httpx
1919
from mcp_types.version import is_version_at_least
2020
from pydantic import BaseModel, Field, ValidationError
2121

22-
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
22+
from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
2323
from mcp.client.auth.utils import (
2424
build_oauth_authorization_server_metadata_discovery_urls,
2525
build_protected_resource_metadata_discovery_urls,
@@ -48,7 +48,6 @@
4848
OAuthMetadata,
4949
OAuthToken,
5050
ProtectedResourceMetadata,
51-
TokenEndpointAuthMethod,
5251
)
5352
from mcp.shared.auth_utils import (
5453
calculate_token_expiry,
@@ -59,54 +58,28 @@
5958

6059
logger = logging.getLogger(__name__)
6160

62-
# Methods a registered client's record may carry without a token request being an error,
63-
# derived from the set the SDK is willing to request so the two cannot drift. `None`/"none"
64-
# send no client secret. `private_key_jwt` sends none from here either: only
65-
# `PrivateKeyJWTOAuthProvider` signs the assertion, and only in its client-credentials
66-
# exchange, so its inherited refresh path must pass through here without raising - a refresh
67-
# the server then rejects falls back to a fresh client-credentials exchange, which signs.
68-
# Anything else is a method no client here can apply.
69-
_KNOWN_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod))
70-
71-
# Methods that authenticate the token request with the minted `client_secret`; a
72-
# registration assigning one is only usable if the server issued that secret.
73-
_SECRET_TOKEN_ENDPOINT_AUTH_METHODS = ("client_secret_post", "client_secret_basic")
74-
75-
# Methods a registration completed by the authorization-code flow can act on. That flow
76-
# authenticates the token request with the minted client secret (or nothing); it holds no key
77-
# to sign a `private_key_jwt` assertion, so a server assigning that method has registered a
78-
# client this flow cannot use. `PrivateKeyJWTOAuthProvider` never registers dynamically.
79-
_REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = tuple(
80-
method for method in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS if method != "private_key_jwt"
81-
)
82-
83-
84-
def check_registration_usable(client_info: OAuthClientInformationFull) -> None:
85-
"""Confirm a registration this flow completed is one it can act on.
8661

87-
RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to
88-
the client to "check the values in the response to determine if the registration is
89-
sufficient for use". Two substitutions make the minted credentials unusable, and both are
90-
judged here - before the record is persisted or any interactive authorization begins -
91-
rather than surfacing later as an opaque failure at the token endpoint: a token-endpoint
92-
auth method the authorization-code flow cannot apply (one it does not implement, or
93-
`private_key_jwt`, whose assertion this flow has no key to sign), and a secret-based
94-
method the flow could apply but for which the server issued no `client_secret`.
62+
def _build_authorization_url(auth_endpoint: str, auth_params: Mapping[str, str | None]) -> str:
63+
"""Build an authorization URL, preserving any query params already on the endpoint.
9564
96-
Raises:
97-
OAuthRegistrationError: The server registered the client with a
98-
`token_endpoint_auth_method` this flow cannot apply, or with a secret-based
99-
method but no `client_secret`.
65+
Servers may advertise an ``authorization_endpoint`` that already carries query
66+
parameters (e.g. ``https://example.com/authorize?prompt=select_account``).
67+
Naively appending ``?<params>`` would produce an invalid URL with two ``?``
68+
separators, so the existing query is parsed and merged with ``auth_params``.
69+
Flow-generated params take precedence on key conflicts; ``None`` values are
70+
dropped rather than serialized as the literal string ``"None"``. Existing
71+
multi-value query params (e.g. ``?scope=a&scope=b``) are preserved rather
72+
than collapsed, except for keys that the flow overrides.
10073
"""
101-
method = client_info.token_endpoint_auth_method
102-
if method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS:
103-
raise OAuthRegistrationError(
104-
f"Authorization server registered the client with unsupported token_endpoint_auth_method {method!r}"
105-
)
106-
if method in _SECRET_TOKEN_ENDPOINT_AUTH_METHODS and client_info.client_secret is None:
107-
raise OAuthRegistrationError(
108-
f"Authorization server registered the client for {method!r} but issued no client_secret"
109-
)
74+
parsed = urlparse(auth_endpoint)
75+
flow_params = {key: value for key, value in auth_params.items() if value is not None}
76+
# Keep existing endpoint params (including duplicate keys) except those the
77+
# flow overrides, then append the authoritative flow params.
78+
existing = [
79+
(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key not in flow_params
80+
]
81+
merged_params = existing + list(flow_params.items())
82+
return urlunparse(parsed._replace(query=urlencode(merged_params)))
11083

11184

11285
class PKCEParameters(BaseModel):
@@ -153,6 +126,7 @@ class OAuthContext:
153126
storage: TokenStorage
154127
redirect_handler: Callable[[str], Awaitable[None]] | None
155128
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None
129+
timeout: float = 300.0
156130
client_metadata_url: str | None = None
157131

158132
# Discovered metadata
@@ -240,12 +214,6 @@ def prepare_token_auth(
240214
241215
Returns:
242216
Tuple of (updated_data, updated_headers)
243-
244-
Raises:
245-
OAuthTokenError: The client record carries a `token_endpoint_auth_method` this
246-
client does not know. A dynamic registration assigning an unusable method is
247-
rejected earlier, by `check_registration_usable`; this fires for a stored or
248-
pre-registered record that reaches a token request with such a method.
249217
"""
250218
if headers is None:
251219
headers = {} # pragma: no cover
@@ -255,7 +223,7 @@ def prepare_token_auth(
255223

256224
auth_method = self.client_info.token_endpoint_auth_method
257225

258-
if auth_method == "client_secret_basic" and self.client_info.client_secret:
226+
if auth_method == "client_secret_basic" and self.client_info.client_id and self.client_info.client_secret:
259227
# URL-encode client ID and secret per RFC 6749 Section 2.3.1
260228
encoded_id = quote(self.client_info.client_id, safe="")
261229
encoded_secret = quote(self.client_info.client_secret, safe="")
@@ -264,20 +232,17 @@ def prepare_token_auth(
264232
headers["Authorization"] = f"Basic {encoded_credentials}"
265233
# Don't include client_secret in body for basic auth
266234
data = {k: v for k, v in data.items() if k != "client_secret"}
267-
elif auth_method == "client_secret_post" and self.client_info.client_secret:
235+
elif auth_method == "client_secret_post" and self.client_info.client_id and self.client_info.client_secret:
268236
# Include client_id and client_secret in request body (RFC 6749 §2.3.1)
269237
data["client_id"] = self.client_info.client_id
270238
data["client_secret"] = self.client_info.client_secret
271-
elif auth_method not in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS:
272-
raise OAuthTokenError(f"Registered client uses unsupported token_endpoint_auth_method {auth_method!r}")
273-
# For "none" (or absent), don't add any client_secret; "private_key_jwt" adds its
274-
# assertion in the provider that implements it, not here.
239+
# For auth_method == "none", don't add any client_secret
275240

276241
return data, headers
277242

278243

279-
class OAuthClientProvider(httpx2.Auth):
280-
"""OAuth2 authentication for httpx2.
244+
class OAuthClientProvider(httpx.Auth):
245+
"""OAuth2 authentication for httpx.
281246
282247
Handles OAuth flow with automatic client registration and token storage.
283248
"""
@@ -291,6 +256,7 @@ def __init__(
291256
storage: TokenStorage,
292257
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
293258
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
259+
timeout: float = 300.0,
294260
client_metadata_url: str | None = None,
295261
validate_resource_url: Callable[[str, str | None], Awaitable[None]] | None = None,
296262
):
@@ -302,6 +268,7 @@ def __init__(
302268
storage: Token storage implementation.
303269
redirect_handler: Handler for authorization redirects.
304270
callback_handler: Handler for authorization callbacks.
271+
timeout: Timeout for the OAuth flow.
305272
client_metadata_url: URL-based client ID. When provided and the server
306273
advertises client_id_metadata_document_supported=True, this URL will be
307274
used as the client_id instead of performing dynamic client registration.
@@ -327,12 +294,13 @@ def __init__(
327294
storage=storage,
328295
redirect_handler=redirect_handler,
329296
callback_handler=callback_handler,
297+
timeout=timeout,
330298
client_metadata_url=client_metadata_url,
331299
)
332300
self._validate_resource_url_callback = validate_resource_url
333301
self._initialized = False
334302

335-
async def _handle_protected_resource_response(self, response: httpx2.Response) -> bool:
303+
async def _handle_protected_resource_response(self, response: httpx.Response) -> bool:
336304
"""Handle protected resource metadata discovery response.
337305
338306
Per SEP-985, supports fallback when discovery fails at one URL.
@@ -363,7 +331,7 @@ async def _handle_protected_resource_response(self, response: httpx2.Response) -
363331
f"Protected Resource Metadata request failed: {response.status_code}"
364332
) # pragma: no cover
365333

366-
async def _perform_authorization(self) -> httpx2.Request:
334+
async def _perform_authorization(self) -> httpx.Request:
367335
"""Perform the authorization flow."""
368336
auth_code, code_verifier = await self._perform_authorization_code_grant()
369337
token_request = await self._exchange_token_authorization_code(auth_code, code_verifier)
@@ -412,7 +380,7 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
412380
if "offline_access" in self.context.client_metadata.scope.split():
413381
auth_params["prompt"] = "consent"
414382

415-
authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"
383+
authorization_url = _build_authorization_url(auth_endpoint, auth_params)
416384
await self.context.redirect_handler(authorization_url)
417385

418386
# Wait for callback
@@ -438,21 +406,26 @@ def _get_token_endpoint(self) -> str:
438406
token_url = urljoin(auth_base_url, "/token")
439407
return token_url
440408

441-
async def _exchange_token_authorization_code(self, auth_code: str, code_verifier: str) -> httpx2.Request:
409+
async def _exchange_token_authorization_code(
410+
self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = {}
411+
) -> httpx.Request:
442412
"""Build token exchange request for authorization_code flow."""
443413
if self.context.client_metadata.redirect_uris is None:
444414
raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover
445415
if not self.context.client_info:
446416
raise OAuthFlowError("Missing client info") # pragma: no cover
447417

448418
token_url = self._get_token_endpoint()
449-
token_data: dict[str, Any] = {
450-
"grant_type": "authorization_code",
451-
"code": auth_code,
452-
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
453-
"client_id": self.context.client_info.client_id,
454-
"code_verifier": code_verifier,
455-
}
419+
token_data = token_data or {}
420+
token_data.update(
421+
{
422+
"grant_type": "authorization_code",
423+
"code": auth_code,
424+
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
425+
"client_id": self.context.client_info.client_id,
426+
"code_verifier": code_verifier,
427+
}
428+
)
456429

457430
# Only include resource param if conditions are met
458431
if self.context.should_include_resource_param(self.context.protocol_version):
@@ -462,9 +435,9 @@ async def _exchange_token_authorization_code(self, auth_code: str, code_verifier
462435
headers = {"Content-Type": "application/x-www-form-urlencoded"}
463436
token_data, headers = self.context.prepare_token_auth(token_data, headers)
464437

465-
return httpx2.Request("POST", token_url, data=token_data, headers=headers)
438+
return httpx.Request("POST", token_url, data=token_data, headers=headers)
466439

467-
async def _handle_token_response(self, response: httpx2.Response) -> None:
440+
async def _handle_token_response(self, response: httpx.Response) -> None:
468441
"""Handle token exchange response."""
469442
if response.status_code not in {200, 201}:
470443
body = await response.aread()
@@ -486,7 +459,7 @@ async def _handle_token_response(self, response: httpx2.Response) -> None:
486459
self.context.update_token_expiry(token_response)
487460
await self.context.storage.set_tokens(token_response)
488461

489-
async def _refresh_token(self) -> httpx2.Request:
462+
async def _refresh_token(self) -> httpx.Request:
490463
"""Build token refresh request."""
491464
if not self.context.current_tokens or not self.context.current_tokens.refresh_token:
492465
raise OAuthTokenError("No refresh token available") # pragma: no cover
@@ -514,9 +487,9 @@ async def _refresh_token(self) -> httpx2.Request:
514487
headers = {"Content-Type": "application/x-www-form-urlencoded"}
515488
refresh_data, headers = self.context.prepare_token_auth(refresh_data, headers)
516489

517-
return httpx2.Request("POST", token_url, data=refresh_data, headers=headers)
490+
return httpx.Request("POST", token_url, data=refresh_data, headers=headers)
518491

519-
async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
492+
async def _handle_refresh_response(self, response: httpx.Response) -> bool:
520493
"""Handle token refresh response. Returns True if successful."""
521494
if response.status_code != 200:
522495
logger.warning(f"Token refresh failed: {response.status_code}")
@@ -553,12 +526,12 @@ async def _initialize(self) -> None:
553526
self.context.client_info = await self.context.storage.get_client_info()
554527
self._initialized = True
555528

556-
def _add_auth_header(self, request: httpx2.Request) -> None:
529+
def _add_auth_header(self, request: httpx.Request) -> None:
557530
"""Add authorization header to request if we have valid tokens."""
558531
if self.context.current_tokens and self.context.current_tokens.access_token: # pragma: no branch
559532
request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}"
560533

561-
async def _handle_oauth_metadata_response(self, response: httpx2.Response) -> None:
534+
async def _handle_oauth_metadata_response(self, response: httpx.Response) -> None:
562535
content = await response.aread()
563536
metadata = OAuthMetadata.model_validate_json(content)
564537
self.context.oauth_metadata = metadata
@@ -577,8 +550,8 @@ async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None
577550
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
578551
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
579552

580-
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
581-
"""httpx2 auth flow integration."""
553+
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
554+
"""HTTPX auth flow integration."""
582555
async with self.context.lock:
583556
if not self._initialized:
584557
await self._initialize()
@@ -723,7 +696,6 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
723696
)
724697
registration_response = yield registration_request
725698
client_information = await handle_registration_response(registration_response)
726-
check_registration_usable(client_information)
727699
# Only record the issuer when the registration above actually targeted
728700
# the discovered AS — either via its published registration_endpoint,
729701
# or because the resource-origin /register fallback is on the issuer's

0 commit comments

Comments
 (0)