1010import secrets
1111import string
1212import time
13- from collections .abc import AsyncGenerator , Awaitable , Callable
13+ from collections .abc import AsyncGenerator , AsyncIterator , Awaitable , Callable
14+ from contextlib import asynccontextmanager
1415from dataclasses import dataclass , field
1516from typing import Any , Protocol
1617from urllib .parse import quote , urlencode , urljoin , urlparse
@@ -115,7 +116,13 @@ class OAuthContext:
115116 token_expiry_time : float | None = None
116117
117118 # State
118- lock : anyio .Lock = field (default_factory = anyio .Lock )
119+ # Semaphores are intentionally task-agnostic: HTTPX can resume or close an
120+ # auth-flow generator from a different task than the one that yielded it.
121+ # Normal resource requests are still yielded outside these critical sections.
122+ lock : anyio .Semaphore = field (default_factory = lambda : anyio .Semaphore (1 , max_value = 1 ))
123+ # Refresh and authorization transitions share one single-flight lock while
124+ # normal resource requests remain independent.
125+ flow_lock : anyio .Semaphore = field (default_factory = lambda : anyio .Semaphore (1 , max_value = 1 ))
119126
120127 def get_authorization_base_url (self , server_url : str ) -> str :
121128 """Extract base URL by removing path component."""
@@ -488,30 +495,86 @@ async def _handle_oauth_metadata_response(self, response: httpx.Response) -> Non
488495 metadata = OAuthMetadata .model_validate_json (content )
489496 self .context .oauth_metadata = metadata
490497
491- async def async_auth_flow (self , request : httpx .Request ) -> AsyncGenerator [httpx .Request , httpx .Response ]:
492- """HTTPX auth flow integration."""
498+ async def _prepare_request (self , request : httpx .Request ) -> tuple [bool , str | None ]:
499+ """Initialize state and capture request-specific protocol state."""
500+ protocol_version = request .headers .get (MCP_PROTOCOL_VERSION )
493501 async with self .context .lock :
494502 if not self ._initialized :
495503 await self ._initialize () # pragma: no cover
496504
497- # Capture protocol version from request headers
498- self .context .protocol_version = request .headers .get (MCP_PROTOCOL_VERSION )
499-
500- if not self .context .is_token_valid () and self .context .can_refresh_token ():
501- # Try to refresh token
502- refresh_request = await self ._refresh_token () # pragma: no cover
503- refresh_response = yield refresh_request # pragma: no cover
505+ self .context .protocol_version = protocol_version
506+ needs_refresh = not self .context .is_token_valid () and self .context .can_refresh_token ()
507+ return needs_refresh , protocol_version
504508
505- if not await self . _handle_refresh_response ( refresh_response ): # pragma: no cover
506- # Refresh failed, need full re-authentication
507- self ._initialized = False
508-
509- if self .context .is_token_valid ():
509+ async def _add_valid_auth_header ( self , request : httpx . Request ) -> str | None :
510+ """Add the current valid token and return the token that was sent."""
511+ async with self .context . lock :
512+ current_tokens = self . context . current_tokens
513+ if self .context .is_token_valid () and current_tokens is not None :
510514 self ._add_auth_header (request )
515+ return current_tokens .access_token
516+ return None
511517
512- response = yield request
518+ @asynccontextmanager
519+ async def _serialized_transition (self ) -> AsyncIterator [None ]:
520+ """Serialize token-changing OAuth transitions and their state writes."""
521+ async with self .context .flow_lock :
522+ async with self .context .lock :
523+ yield
513524
514- if response .status_code == 401 :
525+ async def async_auth_flow (self , request : httpx .Request ) -> AsyncGenerator [httpx .Request , httpx .Response ]:
526+ """HTTPX auth flow integration."""
527+ needs_refresh , protocol_version = await self ._prepare_request (request )
528+
529+ if needs_refresh :
530+ async with self .context .flow_lock :
531+ refresh_request : httpx .Request | None = None
532+ async with self .context .lock :
533+ self .context .protocol_version = protocol_version
534+ # Another request may have refreshed the token while this
535+ # request was waiting for the single-flight refresh lock.
536+ if not self .context .is_token_valid () and self .context .can_refresh_token ():
537+ refresh_request = await self ._refresh_token () # pragma: no cover
538+
539+ if refresh_request is not None :
540+ # Do not hold the general provider-state lock across
541+ # network I/O. ``flow_lock`` deliberately remains held to
542+ # keep all token-changing transitions single-flight.
543+ refresh_response = yield refresh_request # pragma: no cover
544+
545+ async with self .context .lock :
546+ if not await self ._handle_refresh_response (refresh_response ): # pragma: no cover
547+ # Refresh failed, need full re-authentication
548+ self ._initialized = False
549+
550+ sent_access_token = await self ._add_valid_auth_header (request )
551+
552+ # A GET SSE request can remain open for the session lifetime. Yield it
553+ # outside the state lock so concurrent POST requests can authenticate.
554+ response = yield request
555+
556+ if response .status_code not in (401 , 403 ):
557+ return
558+
559+ # Serialize the exceptional 401/403 state transitions. Their existing
560+ # full authorization flow remains unchanged. Re-check the token only
561+ # after acquiring the lock so concurrent 401 responses cannot start
562+ # redundant authorization flows.
563+ retry_after_authorization = False
564+ async with self ._serialized_transition ():
565+ self .context .protocol_version = protocol_version
566+ current_tokens = self .context .current_tokens
567+ token_changed_since_request = (
568+ response .status_code in (401 , 403 )
569+ and self .context .is_token_valid ()
570+ and current_tokens is not None
571+ and current_tokens .access_token != sent_access_token
572+ )
573+
574+ if token_changed_since_request :
575+ self ._add_auth_header (request )
576+ retry_after_authorization = True
577+ elif response .status_code == 401 :
515578 # Perform full OAuth flow
516579 try :
517580 # OAuth flow must be inline due to generator constraints
@@ -602,7 +665,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
602665
603666 # Retry with new tokens
604667 self ._add_auth_header (request )
605- yield request
668+ retry_after_authorization = True
606669 elif response .status_code == 403 :
607670 # Step 1: Extract error field from WWW-Authenticate header
608671 error = extract_field_from_www_auth (response , "error" )
@@ -624,4 +687,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
624687
625688 # Retry with new tokens
626689 self ._add_auth_header (request )
627- yield request
690+ retry_after_authorization = True
691+
692+ # The retried resource request can itself be a session-long GET.
693+ if retry_after_authorization :
694+ yield request
0 commit comments