7
7
import base64
8
8
import hashlib
9
9
import logging
10
+ import re
10
11
import secrets
11
12
import string
12
13
import time
@@ -203,10 +204,39 @@ def __init__(
203
204
)
204
205
self ._initialized = False
205
206
206
- async def _discover_protected_resource (self ) -> httpx .Request :
207
- """Build discovery request for protected resource metadata."""
208
- auth_base_url = self .context .get_authorization_base_url (self .context .server_url )
209
- url = urljoin (auth_base_url , "/.well-known/oauth-protected-resource" )
207
+ def _extract_resource_metadata_from_www_auth (self , init_response : httpx .Response ) -> str | None :
208
+ """
209
+ Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728.
210
+
211
+ Returns:
212
+ Resource metadata URL if found in WWW-Authenticate header, None otherwise
213
+ """
214
+ if not init_response or init_response .status_code != 401 :
215
+ return None
216
+
217
+ www_auth_header = init_response .headers .get ("WWW-Authenticate" )
218
+ if not www_auth_header :
219
+ return None
220
+
221
+ # Pattern matches: resource_metadata="url" or resource_metadata=url (unquoted)
222
+ pattern = r'resource_metadata=(?:"([^"]+)"|([^\s,]+))'
223
+ match = re .search (pattern , www_auth_header )
224
+
225
+ if match :
226
+ # Return quoted value if present, otherwise unquoted value
227
+ return match .group (1 ) or match .group (2 )
228
+
229
+ return None
230
+
231
+ async def _discover_protected_resource (self , init_response : httpx .Response ) -> httpx .Request :
232
+ # RFC9728: Try to extract resource_metadata URL from WWW-Authenticate header of the initial response
233
+ url = self ._extract_resource_metadata_from_www_auth (init_response )
234
+
235
+ if not url :
236
+ # Fallback to well-known discovery
237
+ auth_base_url = self .context .get_authorization_base_url (self .context .server_url )
238
+ url = urljoin (auth_base_url , "/.well-known/oauth-protected-resource" )
239
+
210
240
return httpx .Request ("GET" , url , headers = {MCP_PROTOCOL_VERSION : LATEST_PROTOCOL_VERSION })
211
241
212
242
async def _handle_protected_resource_response (self , response : httpx .Response ) -> None :
@@ -221,72 +251,32 @@ async def _handle_protected_resource_response(self, response: httpx.Response) ->
221
251
except ValidationError :
222
252
pass
223
253
224
- def _build_well_known_path (self , pathname : str ) -> str :
225
- """Construct well-known path for OAuth metadata discovery."""
226
- well_known_path = f"/.well-known/oauth-authorization-server{ pathname } "
227
- if pathname .endswith ("/" ):
228
- # Strip trailing slash from pathname to avoid double slashes
229
- well_known_path = well_known_path [:- 1 ]
230
- return well_known_path
231
-
232
- def _should_attempt_fallback (self , response_status : int , pathname : str ) -> bool :
233
- """Determine if fallback to root discovery should be attempted."""
234
- return response_status == 404 and pathname != "/"
235
-
236
- async def _try_metadata_discovery (self , url : str ) -> httpx .Request :
237
- """Build metadata discovery request for a specific URL."""
238
- return httpx .Request ("GET" , url , headers = {MCP_PROTOCOL_VERSION : LATEST_PROTOCOL_VERSION })
239
-
240
- async def _discover_oauth_metadata (self ) -> httpx .Request :
241
- """Build OAuth metadata discovery request with fallback support."""
242
- if self .context .auth_server_url :
243
- auth_server_url = self .context .auth_server_url
244
- else :
245
- auth_server_url = self .context .server_url
246
-
247
- # Per RFC 8414, try path-aware discovery first
254
+ def _get_discovery_urls (self ) -> list [str ]:
255
+ """Generate ordered list of (url, type) tuples for discovery attempts."""
256
+ urls : list [str ] = []
257
+ auth_server_url = self .context .auth_server_url or self .context .server_url
248
258
parsed = urlparse (auth_server_url )
249
- well_known_path = self ._build_well_known_path (parsed .path )
250
259
base_url = f"{ parsed .scheme } ://{ parsed .netloc } "
251
- url = urljoin (base_url , well_known_path )
252
-
253
- # Store fallback info for use in response handler
254
- self .context .discovery_base_url = base_url
255
- self .context .discovery_pathname = parsed .path
256
260
257
- return await self ._try_metadata_discovery (url )
261
+ # RFC 8414: Path-aware OAuth discovery
262
+ if parsed .path and parsed .path != "/" :
263
+ oauth_path = f"/.well-known/oauth-authorization-server{ parsed .path .rstrip ('/' )} "
264
+ urls .append (urljoin (base_url , oauth_path ))
258
265
259
- async def _discover_oauth_metadata_fallback (self ) -> httpx .Request :
260
- """Build fallback OAuth metadata discovery request for legacy servers."""
261
- base_url = getattr (self .context , "discovery_base_url" , "" )
262
- if not base_url :
263
- raise OAuthFlowError ("No base URL available for fallback discovery" )
266
+ # OAuth root fallback
267
+ urls .append (urljoin (base_url , "/.well-known/oauth-authorization-server" ))
264
268
265
- # Fallback to root discovery for legacy servers
266
- url = urljoin (base_url , "/.well-known/oauth-authorization-server" )
267
- return await self ._try_metadata_discovery (url )
268
-
269
- async def _handle_oauth_metadata_response (self , response : httpx .Response , is_fallback : bool = False ) -> bool :
270
- """Handle OAuth metadata response. Returns True if handled successfully."""
271
- if response .status_code == 200 :
272
- try :
273
- content = await response .aread ()
274
- metadata = OAuthMetadata .model_validate_json (content )
275
- self .context .oauth_metadata = metadata
276
- # Apply default scope if none specified
277
- if self .context .client_metadata .scope is None and metadata .scopes_supported is not None :
278
- self .context .client_metadata .scope = " " .join (metadata .scopes_supported )
279
- return True
280
- except ValidationError :
281
- pass
269
+ # RFC 8414 section 5: Path-aware OIDC discovery
270
+ # See https://www.rfc-editor.org/rfc/rfc8414.html#section-5
271
+ if parsed .path and parsed .path != "/" :
272
+ oidc_path = f"/.well-known/openid-configuration{ parsed .path .rstrip ('/' )} "
273
+ urls .append (urljoin (base_url , oidc_path ))
282
274
283
- # Check if we should attempt fallback (404 on path-aware discovery)
284
- if not is_fallback and self ._should_attempt_fallback (
285
- response .status_code , getattr (self .context , "discovery_pathname" , "/" )
286
- ):
287
- return False # Signal that fallback should be attempted
275
+ # OIDC 1.0 fallback (appends to full URL per OIDC spec)
276
+ oidc_fallback = f"{ auth_server_url .rstrip ('/' )} /.well-known/openid-configuration"
277
+ urls .append (oidc_fallback )
288
278
289
- return True # Signal no fallback needed (either success or non-404 error)
279
+ return urls
290
280
291
281
async def _register_client (self ) -> httpx .Request | None :
292
282
"""Build registration request or skip if already registered."""
@@ -481,6 +471,17 @@ def _add_auth_header(self, request: httpx.Request) -> None:
481
471
if self .context .current_tokens and self .context .current_tokens .access_token :
482
472
request .headers ["Authorization" ] = f"Bearer { self .context .current_tokens .access_token } "
483
473
474
+ def _create_oauth_metadata_request (self , url : str ) -> httpx .Request :
475
+ return httpx .Request ("GET" , url , headers = {MCP_PROTOCOL_VERSION : LATEST_PROTOCOL_VERSION })
476
+
477
+ async def _handle_oauth_metadata_response (self , response : httpx .Response ) -> None :
478
+ content = await response .aread ()
479
+ metadata = OAuthMetadata .model_validate_json (content )
480
+ 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 )
484
+
484
485
async def async_auth_flow (self , request : httpx .Request ) -> AsyncGenerator [httpx .Request , httpx .Response ]:
485
486
"""HTTPX auth flow integration."""
486
487
async with self .context .lock :
@@ -490,77 +491,43 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
490
491
# Capture protocol version from request headers
491
492
self .context .protocol_version = request .headers .get (MCP_PROTOCOL_VERSION )
492
493
493
- # Perform OAuth flow if not authenticated
494
- if not self .context .is_token_valid ():
495
- try :
496
- # OAuth flow must be inline due to generator constraints
497
- # Step 1: Discover protected resource metadata (spec revision 2025-06-18)
498
- discovery_request = await self ._discover_protected_resource ()
499
- discovery_response = yield discovery_request
500
- await self ._handle_protected_resource_response (discovery_response )
501
-
502
- # Step 2: Discover OAuth metadata (with fallback for legacy servers)
503
- oauth_request = await self ._discover_oauth_metadata ()
504
- oauth_response = yield oauth_request
505
- handled = await self ._handle_oauth_metadata_response (oauth_response , is_fallback = False )
506
-
507
- # If path-aware discovery failed with 404, try fallback to root
508
- if not handled :
509
- fallback_request = await self ._discover_oauth_metadata_fallback ()
510
- fallback_response = yield fallback_request
511
- await self ._handle_oauth_metadata_response (fallback_response , is_fallback = True )
512
-
513
- # Step 3: Register client if needed
514
- registration_request = await self ._register_client ()
515
- if registration_request :
516
- registration_response = yield registration_request
517
- await self ._handle_registration_response (registration_response )
518
-
519
- # Step 4: Perform authorization
520
- auth_code , code_verifier = await self ._perform_authorization ()
521
-
522
- # Step 5: Exchange authorization code for tokens
523
- token_request = await self ._exchange_token (auth_code , code_verifier )
524
- token_response = yield token_request
525
- await self ._handle_token_response (token_response )
526
- except Exception :
527
- logger .exception ("OAuth flow error" )
528
- raise
529
-
530
- # Add authorization header and make request
531
- self ._add_auth_header (request )
532
- response = yield request
533
-
534
- # Handle 401 responses
535
- if response .status_code == 401 and self .context .can_refresh_token ():
494
+ if not self .context .is_token_valid () and self .context .can_refresh_token ():
536
495
# Try to refresh token
537
496
refresh_request = await self ._refresh_token ()
538
497
refresh_response = yield refresh_request
539
498
540
- if await self ._handle_refresh_response (refresh_response ):
541
- # Retry original request with new token
542
- self ._add_auth_header (request )
543
- yield request
544
- else :
499
+ if not await self ._handle_refresh_response (refresh_response ):
545
500
# Refresh failed, need full re-authentication
546
501
self ._initialized = False
547
502
503
+ if self .context .is_token_valid ():
504
+ self ._add_auth_header (request )
505
+
506
+ response = yield request
507
+
508
+ if response .status_code == 401 :
509
+ # Perform full OAuth flow
510
+ try :
548
511
# OAuth flow must be inline due to generator constraints
549
- # Step 1: Discover protected resource metadata (spec revision 2025-06-18 )
550
- discovery_request = await self ._discover_protected_resource ()
512
+ # Step 1: Discover protected resource metadata (RFC9728 with WWW-Authenticate support )
513
+ discovery_request = await self ._discover_protected_resource (response )
551
514
discovery_response = yield discovery_request
552
515
await self ._handle_protected_resource_response (discovery_response )
553
516
554
517
# Step 2: Discover OAuth metadata (with fallback for legacy servers)
555
- oauth_request = await self ._discover_oauth_metadata ()
556
- oauth_response = yield oauth_request
557
- handled = await self ._handle_oauth_metadata_response (oauth_response , is_fallback = False )
558
-
559
- # If path-aware discovery failed with 404, try fallback to root
560
- if not handled :
561
- fallback_request = await self ._discover_oauth_metadata_fallback ()
562
- fallback_response = yield fallback_request
563
- await self ._handle_oauth_metadata_response (fallback_response , is_fallback = True )
518
+ discovery_urls = self ._get_discovery_urls ()
519
+ for url in discovery_urls :
520
+ oauth_metadata_request = self ._create_oauth_metadata_request (url )
521
+ oauth_metadata_response = yield oauth_metadata_request
522
+
523
+ if oauth_metadata_response .status_code == 200 :
524
+ try :
525
+ await self ._handle_oauth_metadata_response (oauth_metadata_response )
526
+ break
527
+ except ValidationError :
528
+ continue
529
+ elif oauth_metadata_response .status_code < 400 or oauth_metadata_response .status_code >= 500 :
530
+ break # Non-4XX error, stop trying
564
531
565
532
# Step 3: Register client if needed
566
533
registration_request = await self ._register_client ()
@@ -575,7 +542,10 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
575
542
token_request = await self ._exchange_token (auth_code , code_verifier )
576
543
token_response = yield token_request
577
544
await self ._handle_token_response (token_response )
545
+ except Exception :
546
+ logger .exception ("OAuth flow error" )
547
+ raise
578
548
579
- # Retry with new tokens
580
- self ._add_auth_header (request )
581
- yield request
549
+ # Retry with new tokens
550
+ self ._add_auth_header (request )
551
+ yield request
0 commit comments