Skip to content

Commit 5a311f7

Browse files
committed
step-up auth flow and fix tests
1 parent b0cf94c commit 5a311f7

File tree

3 files changed

+1134
-1193
lines changed

3 files changed

+1134
-1193
lines changed

src/mcp/client/auth.py

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,6 @@ class OAuthContext:
9797
oauth_metadata: OAuthMetadata | None = None
9898
auth_server_url: str | None = None
9999
protocol_version: str | None = None
100-
www_authenticate_scope: str | None = None
101100

102101
# Client registration
103102
client_info: OAuthClientInformationFull | None = None
@@ -212,9 +211,6 @@ def _extract_field_from_www_auth(self, init_response: httpx.Response, field_name
212211
Returns:
213212
Field value if found in WWW-Authenticate header, None otherwise
214213
"""
215-
if not init_response or init_response.status_code != 401:
216-
return None
217-
218214
www_auth_header = init_response.headers.get("WWW-Authenticate")
219215
if not www_auth_header:
220216
return None
@@ -236,6 +232,9 @@ def _extract_resource_metadata_from_www_auth(self, init_response: httpx.Response
236232
Returns:
237233
Resource metadata URL if found in WWW-Authenticate header, None otherwise
238234
"""
235+
if not init_response or init_response.status_code != 401:
236+
return None
237+
239238
return self._extract_field_from_www_auth(init_response, "resource_metadata")
240239

241240
def _extract_scope_from_www_auth(self, init_response: httpx.Response) -> str | None:
@@ -268,26 +267,31 @@ async def _handle_protected_resource_response(self, response: httpx.Response) ->
268267
if metadata.authorization_servers:
269268
self.context.auth_server_url = str(metadata.authorization_servers[0])
270269

271-
# Per MCP spec, scope selection priority order:
272-
# 1. Keep client scope if configured
273-
# 2. Use scope from WWW-Authenticate header (if provided)
274-
# 3. Use all scopes from PRM scopes_supported (if available)
275-
# 4. Omit scope parameter if neither is available
276-
#
277-
# Priority 1: Don't touch if client scope is already configured
278-
if self.context.client_metadata.scope is None:
279-
if self.context.www_authenticate_scope is not None:
280-
# Priority 2: WWW-Authenticate header scope
281-
self.context.client_metadata.scope = self.context.www_authenticate_scope
282-
elif self.context.protected_resource_metadata.scopes_supported is not None:
283-
# Priority 3: PRM scopes_supported
284-
self.context.client_metadata.scope = " ".join(
285-
self.context.protected_resource_metadata.scopes_supported
286-
)
287-
# Priority 4: Omit scope parameter
288-
289270
except ValidationError:
290271
pass
272+
else:
273+
raise OAuthFlowError(f"Protected Resource Metadata request failed: {response.status_code}")
274+
275+
def _configure_scope_selection(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+
# Step 1: Extract scope from WWW-Authenticate header
283+
www_authenticate_scope = self._extract_scope_from_www_auth(init_response)
284+
if www_authenticate_scope is not None:
285+
# Priority 1: WWW-Authenticate header scope
286+
self.context.client_metadata.scope = www_authenticate_scope
287+
elif self.context.protected_resource_metadata is not None and self.context.protected_resource_metadata.scopes_supported is not None:
288+
# Priority 2: PRM scopes_supported
289+
self.context.client_metadata.scope = " ".join(
290+
self.context.protected_resource_metadata.scopes_supported
291+
)
292+
else:
293+
# Priority 3: Omit scope parameter
294+
self.context.client_metadata.scope = None
291295

292296
def _get_discovery_urls(self) -> list[str]:
293297
"""Generate ordered list of (url, type) tuples for discovery attempts."""
@@ -544,14 +548,14 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
544548
# Perform full OAuth flow
545549
try:
546550
# OAuth flow must be inline due to generator constraints
547-
# Step 1: Extract scope from WWW-Authenticate header
548-
self.context.www_authenticate_scope = self._extract_scope_from_www_auth(response)
549-
550-
# Step 2: Discover protected resource metadata (RFC9728 with WWW-Authenticate support)
551+
# Step 1: Discover protected resource metadata (RFC9728 with WWW-Authenticate support)
551552
discovery_request = await self._discover_protected_resource(response)
552553
discovery_response = yield discovery_request
553554
await self._handle_protected_resource_response(discovery_response)
554555

556+
# Step 2: Apply scope selection strategy
557+
self._configure_scope_selection(response)
558+
555559
# Step 3: Discover OAuth metadata (with fallback for legacy servers)
556560
discovery_urls = self._get_discovery_urls()
557561
for url in discovery_urls:
@@ -587,3 +591,27 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
587591
# Retry with new tokens
588592
self._add_auth_header(request)
589593
yield request
594+
elif response.status_code == 403:
595+
try:
596+
# Step 1: Extract error field from WWW-Authenticate header
597+
error = self._extract_field_from_www_auth(response, "error")
598+
599+
# Step 2: Check if we need to step-up authorization
600+
if error == "insufficient_scope":
601+
# Step 2a: Update the required scopes
602+
self._configure_scope_selection(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

Comments
 (0)