@@ -204,22 +204,19 @@ def __init__(
204204 )
205205 self ._initialized = False
206206
207- def _extract_resource_metadata_from_www_auth (self , init_response : httpx .Response ) -> str | None :
207+ def _extract_field_from_www_auth (self , init_response : httpx .Response , field_name : str ) -> str | None :
208208 """
209- Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728 .
209+ Extract field from WWW-Authenticate header.
210210
211211 Returns:
212- Resource metadata URL if found in WWW-Authenticate header, None otherwise
212+ Field value if found in WWW-Authenticate header, None otherwise
213213 """
214- if not init_response or init_response .status_code != 401 :
215- return None
216-
217214 www_auth_header = init_response .headers .get ("WWW-Authenticate" )
218215 if not www_auth_header :
219216 return None
220217
221- # Pattern matches: resource_metadata="url " or resource_metadata=url (unquoted)
222- pattern = r'resource_metadata =(?:"([^"]+)"|([^\s,]+))'
218+ # Pattern matches: field_name="value " or field_name=value (unquoted)
219+ pattern = rf' { field_name } =(?:"([^"]+)"|([^\s,]+))'
223220 match = re .search (pattern , www_auth_header )
224221
225222 if match :
@@ -228,6 +225,27 @@ def _extract_resource_metadata_from_www_auth(self, init_response: httpx.Response
228225
229226 return None
230227
228+ def _extract_resource_metadata_from_www_auth (self , init_response : httpx .Response ) -> str | None :
229+ """
230+ Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728.
231+
232+ Returns:
233+ Resource metadata URL if found in WWW-Authenticate header, None otherwise
234+ """
235+ if not init_response or init_response .status_code != 401 :
236+ return None
237+
238+ return self ._extract_field_from_www_auth (init_response , "resource_metadata" )
239+
240+ def _extract_scope_from_www_auth (self , init_response : httpx .Response ) -> str | None :
241+ """
242+ Extract scope parameter from WWW-Authenticate header as per RFC6750.
243+
244+ Returns:
245+ Scope string if found in WWW-Authenticate header, None otherwise
246+ """
247+ return self ._extract_field_from_www_auth (init_response , "scope" )
248+
231249 async def _discover_protected_resource (self , init_response : httpx .Response ) -> httpx .Request :
232250 # RFC9728: Try to extract resource_metadata URL from WWW-Authenticate header of the initial response
233251 url = self ._extract_resource_metadata_from_www_auth (init_response )
@@ -248,8 +266,32 @@ async def _handle_protected_resource_response(self, response: httpx.Response) ->
248266 self .context .protected_resource_metadata = metadata
249267 if metadata .authorization_servers :
250268 self .context .auth_server_url = str (metadata .authorization_servers [0 ])
269+
251270 except ValidationError :
252271 pass
272+ else :
273+ raise OAuthFlowError (f"Protected Resource Metadata request failed: { response .status_code } " )
274+
275+ def _select_scopes (self , init_response : httpx .Response ) -> None :
276+ """Select scopes as outlined in the 'Scope Selection Strategy in the MCP spec."""
277+ # Per MCP spec, scope selection priority order:
278+ # 1. Use scope from WWW-Authenticate header (if provided)
279+ # 2. Use all scopes from PRM scopes_supported (if available)
280+ # 3. Omit scope parameter if neither is available
281+ #
282+ www_authenticate_scope = self ._extract_scope_from_www_auth (init_response )
283+ if www_authenticate_scope is not None :
284+ # Priority 1: WWW-Authenticate header scope
285+ self .context .client_metadata .scope = www_authenticate_scope
286+ elif (
287+ self .context .protected_resource_metadata is not None
288+ and self .context .protected_resource_metadata .scopes_supported is not None
289+ ):
290+ # Priority 2: PRM scopes_supported
291+ self .context .client_metadata .scope = " " .join (self .context .protected_resource_metadata .scopes_supported )
292+ else :
293+ # Priority 3: Omit scope parameter
294+ self .context .client_metadata .scope = None
253295
254296 def _get_discovery_urls (self ) -> list [str ]:
255297 """Generate ordered list of (url, type) tuples for discovery attempts."""
@@ -478,9 +520,6 @@ async def _handle_oauth_metadata_response(self, response: httpx.Response) -> Non
478520 content = await response .aread ()
479521 metadata = OAuthMetadata .model_validate_json (content )
480522 self .context .oauth_metadata = metadata
481- # Apply default scope if needed
482- if self .context .client_metadata .scope is None and metadata .scopes_supported is not None :
483- self .context .client_metadata .scope = " " .join (metadata .scopes_supported )
484523
485524 async def async_auth_flow (self , request : httpx .Request ) -> AsyncGenerator [httpx .Request , httpx .Response ]:
486525 """HTTPX auth flow integration."""
@@ -514,7 +553,10 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
514553 discovery_response = yield discovery_request
515554 await self ._handle_protected_resource_response (discovery_response )
516555
517- # Step 2: Discover OAuth metadata (with fallback for legacy servers)
556+ # Step 2: Apply scope selection strategy
557+ self ._select_scopes (response )
558+
559+ # Step 3: Discover OAuth metadata (with fallback for legacy servers)
518560 discovery_urls = self ._get_discovery_urls ()
519561 for url in discovery_urls :
520562 oauth_metadata_request = self ._create_oauth_metadata_request (url )
@@ -529,16 +571,16 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
529571 elif oauth_metadata_response .status_code < 400 or oauth_metadata_response .status_code >= 500 :
530572 break # Non-4XX error, stop trying
531573
532- # Step 3 : Register client if needed
574+ # Step 4 : Register client if needed
533575 registration_request = await self ._register_client ()
534576 if registration_request :
535577 registration_response = yield registration_request
536578 await self ._handle_registration_response (registration_response )
537579
538- # Step 4 : Perform authorization
580+ # Step 5 : Perform authorization
539581 auth_code , code_verifier = await self ._perform_authorization ()
540582
541- # Step 5 : Exchange authorization code for tokens
583+ # Step 6 : Exchange authorization code for tokens
542584 token_request = await self ._exchange_token (auth_code , code_verifier )
543585 token_response = yield token_request
544586 await self ._handle_token_response (token_response )
@@ -549,3 +591,27 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
549591 # Retry with new tokens
550592 self ._add_auth_header (request )
551593 yield request
594+ elif response .status_code == 403 :
595+ # Step 1: Extract error field from WWW-Authenticate header
596+ error = self ._extract_field_from_www_auth (response , "error" )
597+
598+ # Step 2: Check if we need to step-up authorization
599+ if error == "insufficient_scope" :
600+ try :
601+ # Step 2a: Update the required scopes
602+ self ._select_scopes (response )
603+
604+ # Step 2b: Perform (re-)authorization
605+ auth_code , code_verifier = await self ._perform_authorization ()
606+
607+ # Step 2c: Exchange authorization code for tokens
608+ token_request = await self ._exchange_token (auth_code , code_verifier )
609+ token_response = yield token_request
610+ await self ._handle_token_response (token_response )
611+ except Exception :
612+ logger .exception ("OAuth flow error" )
613+ raise
614+
615+ # Retry with new tokens
616+ self ._add_auth_header (request )
617+ yield request
0 commit comments