1- """OAuth2 Authentication implementation for httpx2 .
1+ """OAuth2 Authentication implementation for HTTPX .
22
33Implements authorization code flow with PKCE and automatic token refresh.
44"""
99import secrets
1010import string
1111import time
12- from collections .abc import AsyncGenerator , Awaitable , Callable
12+ from collections .abc import AsyncGenerator , Awaitable , Callable , Mapping
1313from 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
1717import anyio
18- import httpx2
18+ import httpx
1919from mcp_types .version import is_version_at_least
2020from 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
2323from mcp .client .auth .utils import (
2424 build_oauth_authorization_server_metadata_discovery_urls ,
2525 build_protected_resource_metadata_discovery_urls ,
4848 OAuthMetadata ,
4949 OAuthToken ,
5050 ProtectedResourceMetadata ,
51- TokenEndpointAuthMethod ,
5251)
5352from mcp .shared .auth_utils import (
5453 calculate_token_expiry ,
5958
6059logger = 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
11285class 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