From de38133ee0ff5a6a9ef4ed2be9439b695079dfae Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:51:17 +0000 Subject: [PATCH 1/2] Fix OAuth discovery fallback and URL ordering This commit addresses two related OAuth discovery issues: 1. Enable fallback to March 2025 spec for legacy servers (issue #1495) - Remove exception when PRM discovery fails completely - Fall back to root-level OAuth discovery for backward compatibility - When PRM unavailable, only check /.well-known/oauth-authorization-server - Maintains compatibility with legacy servers like Linear and Atlassian 2. Fix OAuth discovery URL ordering (issue #1623) - Only check path-based URLs when auth server URL contains a path - Prevents incorrectly discovering root AS when tenant-specific AS exists - Follows RFC 8414 priority: path-aware OAuth, then path-aware OIDC, then root - Fixes issue where root URLs were tried before path-based OIDC URLs Changes: - Renamed build_protected_resource_discovery_urls to build_protected_resource_metadata_discovery_urls - Renamed get_discovery_urls to build_oauth_authorization_server_metadata_discovery_urls - New function signature accepts optional auth_server_url and required server_url - Legacy behavior: when auth_server_url is None, only try root URL - Path-aware behavior: when auth_server_url has path, only try path-based URLs - Root behavior: when auth_server_url has no path, only try root URLs Breaking changes: - Some invalid server configurations will no longer work: - No PRM available and OASM at a path other than root - PRM returns auth server URL with path, but OASM only at root These configurations violate RFC specifications and are not expected to exist. Github-Issue: #1495 Github-Issue: #1623 --- src/mcp/client/auth/oauth2.py | 30 +++++++++++++-------------- src/mcp/client/auth/utils.py | 39 +++++++++++++++++++++++++---------- tests/client/test_auth.py | 22 ++++++++++++-------- 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 1463655ae..f16e84db2 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -21,14 +21,14 @@ from mcp.client.auth import OAuthFlowError, OAuthTokenError from mcp.client.auth.utils import ( - build_protected_resource_discovery_urls, + build_oauth_authorization_server_metadata_discovery_urls, + build_protected_resource_metadata_discovery_urls, create_client_registration_request, create_oauth_metadata_request, extract_field_from_www_auth, extract_resource_metadata_from_www_auth, extract_scope_from_www_auth, get_client_metadata_scopes, - get_discovery_urls, handle_auth_metadata_response, handle_protected_resource_response, handle_registration_response, @@ -463,10 +463,10 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response) # Step 1: Discover protected resource metadata (SEP-985 with fallback support) - prm_discovery_urls = build_protected_resource_discovery_urls( + prm_discovery_urls = build_protected_resource_metadata_discovery_urls( www_auth_resource_metadata_url, self.context.server_url ) - prm_discovery_success = False + for url in prm_discovery_urls: # pragma: no branch discovery_request = create_oauth_metadata_request(url) @@ -474,23 +474,23 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. prm = await handle_protected_resource_response(discovery_response) if prm: - prm_discovery_success = True - - # saving the response metadata self.context.protected_resource_metadata = prm - if prm.authorization_servers: # pragma: no branch - self.context.auth_server_url = str(prm.authorization_servers[0]) + # todo: try all authorization_servers to find the OASM + assert ( + len(prm.authorization_servers) > 0 + ) # this is always true as authorization_servers has a min length of 1 + + self.context.auth_server_url = str(prm.authorization_servers[0]) break else: logger.debug(f"Protected resource metadata discovery failed: {url}") - if not prm_discovery_success: - raise OAuthFlowError( - "Protected resource metadata discovery failed: no valid metadata found" - ) # pragma: no cover - # Step 2: Discover OAuth metadata (with fallback for legacy servers) - asm_discovery_urls = get_discovery_urls(self.context.auth_server_url or self.context.server_url) + asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + self.context.auth_server_url, self.context.server_url + ) + + # Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers) for url in asm_discovery_urls: # pragma: no cover oauth_metadata_request = create_oauth_metadata_request(url) oauth_metadata_response = yield oauth_metadata_request diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 1774c5ff5..bbb3ff52f 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -64,7 +64,7 @@ def extract_resource_metadata_from_www_auth(response: Response) -> str | None: return extract_field_from_www_auth(response, "resource_metadata") -def build_protected_resource_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]: +def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]: """ Build ordered list of URLs to try for protected resource metadata discovery. @@ -126,8 +126,21 @@ def get_client_metadata_scopes( return None -def get_discovery_urls(auth_server_url: str) -> list[str]: - """Generate ordered list of (url, type) tuples for discovery attempts.""" +def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]: + """ + Generate ordered list of (url, type) tuples for discovery attempts. + + Args: + auth_server_url: URL for the OAuth Authorization Metadata URL if found, otherwise None + server_url: URL for the MCP server, used as a fallback if auth_server_url is None + """ + + if not auth_server_url: + # Legacy path using the 2025-03-26 spec: + # link: https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization + parsed = urlparse(server_url) + return [f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-authorization-server"] + urls: list[str] = [] parsed = urlparse(auth_server_url) base_url = f"{parsed.scheme}://{parsed.netloc}" @@ -137,18 +150,22 @@ def get_discovery_urls(auth_server_url: str) -> list[str]: oauth_path = f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}" urls.append(urljoin(base_url, oauth_path)) - # OAuth root fallback - urls.append(urljoin(base_url, "/.well-known/oauth-authorization-server")) - - # RFC 8414 section 5: Path-aware OIDC discovery - # See https://www.rfc-editor.org/rfc/rfc8414.html#section-5 - if parsed.path and parsed.path != "/": + # RFC 8414 section 5: Path-aware OIDC discovery + # See https://www.rfc-editor.org/rfc/rfc8414.html#section-5 oidc_path = f"/.well-known/openid-configuration{parsed.path.rstrip('/')}" urls.append(urljoin(base_url, oidc_path)) + # https://openid.net/specs/openid-connect-discovery-1_0.html + oidc_path = f"{parsed.path.rstrip('/')}/.well-known/openid-configuration" + urls.append(urljoin(base_url, oidc_path)) + return urls + + # OAuth root + urls.append(urljoin(base_url, "/.well-known/oauth-authorization-server")) + # OIDC 1.0 fallback (appends to full URL per OIDC spec) - oidc_fallback = f"{auth_server_url.rstrip('/')}/.well-known/openid-configuration" - urls.append(oidc_fallback) + # https://openid.net/specs/openid-connect-discovery-1_0.html + urls.append(urljoin(base_url, "/.well-known/openid-configuration")) return urls diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 46a552e58..07ca2af9b 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -12,13 +12,13 @@ from mcp.client.auth import OAuthClientProvider, PKCEParameters from mcp.client.auth.utils import ( - build_protected_resource_discovery_urls, + build_oauth_authorization_server_metadata_discovery_urls, + build_protected_resource_metadata_discovery_urls, create_oauth_metadata_request, extract_field_from_www_auth, extract_resource_metadata_from_www_auth, extract_scope_from_www_auth, get_client_metadata_scopes, - get_discovery_urls, handle_registration_response, ) from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken, ProtectedResourceMetadata @@ -275,7 +275,7 @@ async def callback_handler() -> tuple[str, str | None]: status_code=401, headers={}, request=httpx.Request("GET", "https://request-api.example.com") ) - urls = build_protected_resource_discovery_urls( + urls = build_protected_resource_metadata_discovery_urls( extract_resource_metadata_from_www_auth(init_response), provider.context.server_url ) assert len(urls) == 1 @@ -286,7 +286,7 @@ async def callback_handler() -> tuple[str, str | None]: 'Bearer resource_metadata="https://prm.example.com/.well-known/oauth-protected-resource/path"' ) - urls = build_protected_resource_discovery_urls( + urls = build_protected_resource_metadata_discovery_urls( extract_resource_metadata_from_www_auth(init_response), provider.context.server_url ) assert len(urls) == 2 @@ -309,12 +309,16 @@ class TestOAuthFallback: @pytest.mark.anyio async def test_oauth_discovery_fallback_order(self, oauth_provider: OAuthClientProvider): - """Test fallback URL construction order.""" - discovery_urls = get_discovery_urls(oauth_provider.context.auth_server_url or oauth_provider.context.server_url) + """Test fallback URL construction order when auth server URL has a path.""" + # Simulate PRM discovery returning an auth server URL with a path + oauth_provider.context.auth_server_url = oauth_provider.context.server_url + + discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + oauth_provider.context.auth_server_url, oauth_provider.context.server_url + ) assert discovery_urls == [ "https://api.example.com/.well-known/oauth-authorization-server/v1/mcp", - "https://api.example.com/.well-known/oauth-authorization-server", "https://api.example.com/.well-known/openid-configuration/v1/mcp", "https://api.example.com/v1/mcp/.well-known/openid-configuration", ] @@ -1084,7 +1088,7 @@ async def callback_handler() -> tuple[str, str | None]: ) # Build discovery URLs - discovery_urls = build_protected_resource_discovery_urls( + discovery_urls = build_protected_resource_metadata_discovery_urls( extract_resource_metadata_from_www_auth(init_response), provider.context.server_url ) @@ -1224,7 +1228,7 @@ async def callback_handler() -> tuple[str, str | None]: ) # Build discovery URLs - discovery_urls = build_protected_resource_discovery_urls( + discovery_urls = build_protected_resource_metadata_discovery_urls( extract_resource_metadata_from_www_auth(init_response), provider.context.server_url ) From 196017906da0c153556dc8827f6cd94952e8972f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:08:07 +0000 Subject: [PATCH 2/2] Add comprehensive tests for OAuth discovery fallback behavior This commit adds and updates tests to properly cover the new OAuth discovery logic for legacy server compatibility and path-aware discovery. New tests: - test_oauth_discovery_legacy_fallback_when_no_prm: Verifies that when PRM discovery fails, only root OAuth URL is tried (March 2025 spec) - test_oauth_discovery_path_aware_when_auth_server_has_path: Ensures path-based URLs are tried when auth server URL has a path - test_oauth_discovery_root_when_auth_server_has_no_path: Ensures root URLs are tried when auth server URL has no path - test_oauth_discovery_root_when_auth_server_has_only_slash: Handles trailing slash edge case - test_legacy_server_no_prm_falls_back_to_root_oauth_discovery: End-to-end test simulating Linear-style legacy servers - test_legacy_server_with_different_prm_and_root_urls: Tests fallback with custom WWW-Authenticate PRM URLs Updated tests: - test_oauth_discovery_fallback_conditions: Updated to reflect new path-aware discovery behavior (no root URLs when auth server has path) - test_oauth_discovery_fallback_order: Simplified to focus on path-aware case All tests pass with proper linting and type checking. Github-Issue: #1495 Github-Issue: #1623 --- tests/client/test_auth.py | 269 +++++++++++++++++++++++++++++++++++++- 1 file changed, 263 insertions(+), 6 deletions(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 07ca2af9b..e9a81192a 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -307,6 +307,57 @@ def test_create_oauth_metadata_request(self, oauth_provider: OAuthClientProvider class TestOAuthFallback: """Test OAuth discovery fallback behavior for legacy (act as AS not RS) servers.""" + @pytest.mark.anyio + async def test_oauth_discovery_legacy_fallback_when_no_prm(self): + """Test that when PRM discovery fails, only root OAuth URL is tried (March 2025 spec).""" + # When auth_server_url is None (PRM failed), we use server_url and only try root + discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(None, "https://mcp.linear.app/sse") + + # Should only try the root URL (legacy behavior) + assert discovery_urls == [ + "https://mcp.linear.app/.well-known/oauth-authorization-server", + ] + + @pytest.mark.anyio + async def test_oauth_discovery_path_aware_when_auth_server_has_path(self): + """Test that when auth server URL has a path, only path-based URLs are tried.""" + discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + "https://auth.example.com/tenant1", "https://api.example.com/mcp" + ) + + # Should try path-based URLs only (no root URLs) + assert discovery_urls == [ + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + "https://auth.example.com/.well-known/openid-configuration/tenant1", + "https://auth.example.com/tenant1/.well-known/openid-configuration", + ] + + @pytest.mark.anyio + async def test_oauth_discovery_root_when_auth_server_has_no_path(self): + """Test that when auth server URL has no path, only root URLs are tried.""" + discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + "https://auth.example.com", "https://api.example.com/mcp" + ) + + # Should try root URLs only + assert discovery_urls == [ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + ] + + @pytest.mark.anyio + async def test_oauth_discovery_root_when_auth_server_has_only_slash(self): + """Test that when auth server URL has only trailing slash, treated as root.""" + discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + "https://auth.example.com/", "https://api.example.com/mcp" + ) + + # Should try root URLs only + assert discovery_urls == [ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + ] + @pytest.mark.anyio async def test_oauth_discovery_fallback_order(self, oauth_provider: OAuthClientProvider): """Test fallback URL construction order when auth server URL has a path.""" @@ -362,13 +413,14 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl assert discovery_request.method == "GET" # Send a successful discovery response with minimal protected resource metadata + # Note: auth server URL has a path (/v1/mcp), so only path-based URLs will be tried discovery_response = httpx.Response( 200, content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com/v1/mcp"]}', request=discovery_request, ) - # Next request should be to discover OAuth metadata + # Next request should be to discover OAuth metadata at path-aware OAuth URL oauth_metadata_request_1 = await auth_flow.asend(discovery_response) assert ( str(oauth_metadata_request_1.url) @@ -383,9 +435,9 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl request=oauth_metadata_request_1, ) - # Next request should be to discover OAuth metadata at the next endpoint + # Next request should be path-aware OIDC URL (not root URL since auth server has path) oauth_metadata_request_2 = await auth_flow.asend(oauth_metadata_response_1) - assert str(oauth_metadata_request_2.url) == "https://auth.example.com/.well-known/oauth-authorization-server" + assert str(oauth_metadata_request_2.url) == "https://auth.example.com/.well-known/openid-configuration/v1/mcp" assert oauth_metadata_request_2.method == "GET" # Send a 400 response @@ -395,9 +447,9 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl request=oauth_metadata_request_2, ) - # Next request should be to discover OAuth metadata at the next endpoint + # Next request should be OIDC path-appended URL oauth_metadata_request_3 = await auth_flow.asend(oauth_metadata_response_2) - assert str(oauth_metadata_request_3.url) == "https://auth.example.com/.well-known/openid-configuration/v1/mcp" + assert str(oauth_metadata_request_3.url) == "https://auth.example.com/v1/mcp/.well-known/openid-configuration" assert oauth_metadata_request_3.method == "GET" # Send a 500 response @@ -412,7 +464,8 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthCl return_value=("test_auth_code", "test_code_verifier") ) - # Next request should fall back to legacy behavior and auth with the RS (mocked /authorize, next is /token) + # All path-based URLs failed, flow continues with default endpoints + # Next request should be token exchange using MCP server base URL (fallback when OAuth metadata not found) token_request = await auth_flow.asend(oauth_metadata_response_3) assert str(token_request.url) == "https://api.example.com/token" assert token_request.method == "POST" @@ -1059,6 +1112,210 @@ def test_build_metadata( ) +class TestLegacyServerFallback: + """Test backward compatibility with legacy servers that don't support PRM (issue #1495).""" + + @pytest.mark.anyio + async def test_legacy_server_no_prm_falls_back_to_root_oauth_discovery( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test that when PRM discovery fails completely, we fall back to root OAuth discovery (March 2025 spec).""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + # Simulate a legacy server like Linear + provider = OAuthClientProvider( + server_url="https://mcp.linear.app/sse", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + # Mock client info to skip DCR + provider.context.client_info = OAuthClientInformationFull( + client_id="existing_client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + test_request = httpx.Request("GET", "https://mcp.linear.app/sse") + auth_flow = provider.async_auth_flow(test_request) + + # First request + request = await auth_flow.__anext__() + assert "Authorization" not in request.headers + + # Send 401 without WWW-Authenticate header (typical legacy server) + response = httpx.Response(401, headers={}, request=test_request) + + # Should try path-based PRM first + prm_request_1 = await auth_flow.asend(response) + assert str(prm_request_1.url) == "https://mcp.linear.app/.well-known/oauth-protected-resource/sse" + + # PRM returns 404 + prm_response_1 = httpx.Response(404, request=prm_request_1) + + # Should try root-based PRM + prm_request_2 = await auth_flow.asend(prm_response_1) + assert str(prm_request_2.url) == "https://mcp.linear.app/.well-known/oauth-protected-resource" + + # PRM returns 404 again - all PRM URLs failed + prm_response_2 = httpx.Response(404, request=prm_request_2) + + # Should fall back to root OAuth discovery (March 2025 spec behavior) + oauth_metadata_request = await auth_flow.asend(prm_response_2) + assert str(oauth_metadata_request.url) == "https://mcp.linear.app/.well-known/oauth-authorization-server" + assert oauth_metadata_request.method == "GET" + + # Send successful OAuth metadata response + oauth_metadata_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://mcp.linear.app", ' + b'"authorization_endpoint": "https://mcp.linear.app/authorize", ' + b'"token_endpoint": "https://mcp.linear.app/token"}' + ), + request=oauth_metadata_request, + ) + + # Mock authorization + provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) + + # Next should be token exchange + token_request = await auth_flow.asend(oauth_metadata_response) + assert str(token_request.url) == "https://mcp.linear.app/token" + + # Send successful token response + token_response = httpx.Response( + 200, + content=b'{"access_token": "linear_token", "token_type": "Bearer", "expires_in": 3600}', + request=token_request, + ) + + # Final request with auth header + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer linear_token" + assert str(final_request.url) == "https://mcp.linear.app/sse" + + # Complete flow + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass + + @pytest.mark.anyio + async def test_legacy_server_with_different_prm_and_root_urls( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test PRM fallback with different WWW-Authenticate and root URLs.""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + provider.context.client_info = OAuthClientInformationFull( + client_id="existing_client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + test_request = httpx.Request("GET", "https://api.example.com/v1/mcp") + auth_flow = provider.async_auth_flow(test_request) + + await auth_flow.__anext__() + + # 401 with custom WWW-Authenticate PRM URL + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="https://custom.prm.com/.well-known/oauth-protected-resource"' + }, + request=test_request, + ) + + # Try custom PRM URL first + prm_request_1 = await auth_flow.asend(response) + assert str(prm_request_1.url) == "https://custom.prm.com/.well-known/oauth-protected-resource" + + # Returns 500 + prm_response_1 = httpx.Response(500, request=prm_request_1) + + # Try path-based fallback + prm_request_2 = await auth_flow.asend(prm_response_1) + assert str(prm_request_2.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp" + + # Returns 404 + prm_response_2 = httpx.Response(404, request=prm_request_2) + + # Try root fallback + prm_request_3 = await auth_flow.asend(prm_response_2) + assert str(prm_request_3.url) == "https://api.example.com/.well-known/oauth-protected-resource" + + # Also returns 404 - all PRM URLs failed + prm_response_3 = httpx.Response(404, request=prm_request_3) + + # Should fall back to root OAuth discovery + oauth_metadata_request = await auth_flow.asend(prm_response_3) + assert str(oauth_metadata_request.url) == "https://api.example.com/.well-known/oauth-authorization-server" + + # Complete the flow + oauth_metadata_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://api.example.com", ' + b'"authorization_endpoint": "https://api.example.com/authorize", ' + b'"token_endpoint": "https://api.example.com/token"}' + ), + request=oauth_metadata_request, + ) + + provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) + + token_request = await auth_flow.asend(oauth_metadata_response) + assert str(token_request.url) == "https://api.example.com/token" + + token_response = httpx.Response( + 200, + content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}', + request=token_request, + ) + + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer test_token" + + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass + + class TestSEP985Discovery: """Test SEP-985 protected resource metadata discovery with fallback."""