Skip to content

Commit b32c4c6

Browse files
cbcoutinhoclaude
andcommitted
Implement SEP-985: OAuth Protected Resource Metadata discovery fallback
Add support for multiple discovery mechanisms when WWW-Authenticate header is absent, aligning with RFC 9728 requirements. Clients now try discovery URLs in order: WWW-Authenticate header resource_metadata parameter, path-based well-known URI, then root-based well-known URI. Changes: - Add discovery state tracking to OAuthContext (discovery_urls, discovery_index) - Implement _build_protected_resource_discovery_urls() to generate ordered URL list - Update _discover_protected_resource() to support multi-step discovery - Modify _handle_protected_resource_response() to return success/failure boolean - Update async_auth_flow() to loop through discovery URLs with fallback logic - Add comprehensive test coverage for all three discovery mechanisms This enables servers to choose between WWW-Authenticate headers and well-known URIs based on their deployment architecture, reducing integration complexity for large-scale, multi-tenant environments. Github-Issue: #1341 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6c26d08 commit b32c4c6

File tree

2 files changed

+280
-16
lines changed

2 files changed

+280
-16
lines changed

src/mcp/client/auth.py

Lines changed: 102 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,9 @@ class OAuthContext:
108108
# State
109109
lock: anyio.Lock = field(default_factory=anyio.Lock)
110110

111-
# Discovery state for fallback support
112-
discovery_base_url: str | None = None
113-
discovery_pathname: str | None = None
111+
# Discovery state for fallback support (SEP-985)
112+
discovery_urls: list[str] = field(default_factory=lambda: [])
113+
discovery_index: int = 0
114114

115115
def get_authorization_base_url(self, server_url: str) -> str:
116116
"""Extract base URL by removing path component."""
@@ -141,6 +141,11 @@ def clear_tokens(self) -> None:
141141
self.current_tokens = None
142142
self.token_expiry_time = None
143143

144+
def reset_discovery_state(self) -> None:
145+
"""Reset protected resource metadata discovery state."""
146+
self.discovery_urls = []
147+
self.discovery_index = 0
148+
144149
def get_resource_url(self) -> str:
145150
"""Get resource URL for RFC 8707.
146151
@@ -204,6 +209,43 @@ def __init__(
204209
)
205210
self._initialized = False
206211

212+
def _build_protected_resource_discovery_urls(self, init_response: httpx.Response) -> list[str]:
213+
"""
214+
Build ordered list of URLs to try for protected resource metadata discovery.
215+
216+
Per SEP-985, the client MUST:
217+
1. Try resource_metadata from WWW-Authenticate header (if present)
218+
2. Fall back to path-based well-known URI: /.well-known/oauth-protected-resource/{path}
219+
3. Fall back to root-based well-known URI: /.well-known/oauth-protected-resource
220+
221+
Args:
222+
init_response: The initial 401 response from the server
223+
224+
Returns:
225+
Ordered list of URLs to try for discovery
226+
"""
227+
urls: list[str] = []
228+
229+
# Priority 1: WWW-Authenticate header with resource_metadata parameter
230+
www_auth_url = self._extract_resource_metadata_from_www_auth(init_response)
231+
if www_auth_url:
232+
urls.append(www_auth_url)
233+
234+
# Priority 2-3: Well-known URIs (RFC 9728)
235+
parsed = urlparse(self.context.server_url)
236+
base_url = f"{parsed.scheme}://{parsed.netloc}"
237+
238+
# Priority 2: Path-based well-known URI (if server has a path component)
239+
if parsed.path and parsed.path != "/":
240+
path_based_url = urljoin(base_url, f"/.well-known/oauth-protected-resource{parsed.path}")
241+
urls.append(path_based_url)
242+
243+
# Priority 3: Root-based well-known URI
244+
root_based_url = urljoin(base_url, "/.well-known/oauth-protected-resource")
245+
urls.append(root_based_url)
246+
247+
return urls
248+
207249
def _extract_field_from_www_auth(self, init_response: httpx.Response, field_name: str) -> str | None:
208250
"""
209251
Extract field from WWW-Authenticate header.
@@ -247,29 +289,59 @@ def _extract_scope_from_www_auth(self, init_response: httpx.Response) -> str | N
247289
return self._extract_field_from_www_auth(init_response, "scope")
248290

249291
async def _discover_protected_resource(self, init_response: httpx.Response) -> httpx.Request:
250-
# RFC9728: Try to extract resource_metadata URL from WWW-Authenticate header of the initial response
251-
url = self._extract_resource_metadata_from_www_auth(init_response)
292+
"""
293+
Build protected resource metadata discovery request.
252294
253-
if not url:
254-
# Fallback to well-known discovery
255-
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
256-
url = urljoin(auth_base_url, "/.well-known/oauth-protected-resource")
295+
Per SEP-985, supports multiple discovery mechanisms with fallback:
296+
1. WWW-Authenticate header (if present)
297+
2. Path-based well-known URI
298+
3. Root-based well-known URI
299+
300+
Returns:
301+
Request for the next discovery URL to try
302+
"""
303+
# Initialize discovery URLs on first call
304+
if not self.context.discovery_urls:
305+
self.context.discovery_urls = self._build_protected_resource_discovery_urls(init_response)
306+
self.context.discovery_index = 0
307+
308+
# Get current URL to try
309+
if self.context.discovery_index < len(self.context.discovery_urls):
310+
url = self.context.discovery_urls[self.context.discovery_index]
311+
else:
312+
# No more URLs to try - this shouldn't happen in normal flow
313+
raise OAuthFlowError("Protected resource metadata discovery failed: all URLs exhausted")
257314

258315
return httpx.Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
259316

260-
async def _handle_protected_resource_response(self, response: httpx.Response) -> None:
261-
"""Handle discovery response."""
317+
async def _handle_protected_resource_response(self, response: httpx.Response) -> bool:
318+
"""
319+
Handle protected resource metadata discovery response.
320+
321+
Per SEP-985, supports fallback when discovery fails at one URL.
322+
323+
Returns:
324+
True if metadata was successfully discovered, False if we should try next URL
325+
"""
262326
if response.status_code == 200:
263327
try:
264328
content = await response.aread()
265329
metadata = ProtectedResourceMetadata.model_validate_json(content)
266330
self.context.protected_resource_metadata = metadata
267331
if metadata.authorization_servers:
268332
self.context.auth_server_url = str(metadata.authorization_servers[0])
333+
return True
269334

270335
except ValidationError:
271-
pass
336+
# Invalid metadata - try next URL
337+
logger.warning(f"Invalid protected resource metadata at {response.request.url}")
338+
return False
339+
elif response.status_code == 404:
340+
# Not found - try next URL in fallback chain
341+
logger.debug(f"Protected resource metadata not found at {response.request.url}, trying next URL")
342+
return False
272343
else:
344+
# Other error - fail immediately
273345
raise OAuthFlowError(f"Protected Resource Metadata request failed: {response.status_code}")
274346

275347
def _select_scopes(self, init_response: httpx.Response) -> None:
@@ -547,11 +619,25 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
547619
if response.status_code == 401:
548620
# Perform full OAuth flow
549621
try:
622+
# Reset discovery state for new OAuth flow
623+
self.context.reset_discovery_state()
624+
550625
# OAuth flow must be inline due to generator constraints
551-
# Step 1: Discover protected resource metadata (RFC9728 with WWW-Authenticate support)
552-
discovery_request = await self._discover_protected_resource(response)
553-
discovery_response = yield discovery_request
554-
await self._handle_protected_resource_response(discovery_response)
626+
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
627+
# Try discovery URLs in order until one succeeds
628+
discovery_success = False
629+
while not discovery_success:
630+
discovery_request = await self._discover_protected_resource(response)
631+
discovery_response = yield discovery_request
632+
discovery_success = await self._handle_protected_resource_response(discovery_response)
633+
634+
if not discovery_success:
635+
# Try next URL in fallback chain
636+
self.context.discovery_index += 1
637+
if self.context.discovery_index >= len(self.context.discovery_urls):
638+
raise OAuthFlowError(
639+
"Protected resource metadata discovery failed: no valid metadata found"
640+
)
555641

556642
# Step 2: Apply scope selection strategy
557643
self._select_scopes(response)

tests/client/test_auth.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ async def callback_handler() -> tuple[str, str | None]:
271271
assert "mcp-protocol-version" in request.headers
272272

273273
# Test with WWW-Authenticate header
274+
# Reset discovery state for new test case
275+
provider.context.reset_discovery_state()
274276
init_response.headers["WWW-Authenticate"] = (
275277
'Bearer resource_metadata="https://prm.example.com/.well-known/oauth-protected-resource/path"'
276278
)
@@ -1030,6 +1032,182 @@ def test_build_metadata(
10301032
)
10311033

10321034

1035+
class TestSEP985Discovery:
1036+
"""Test SEP-985 protected resource metadata discovery with fallback."""
1037+
1038+
@pytest.mark.anyio
1039+
async def test_path_based_fallback_when_no_www_authenticate(
1040+
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
1041+
):
1042+
"""Test that client falls back to path-based well-known URI when WWW-Authenticate is absent."""
1043+
1044+
async def redirect_handler(url: str) -> None:
1045+
pass
1046+
1047+
async def callback_handler() -> tuple[str, str | None]:
1048+
return "test_auth_code", "test_state"
1049+
1050+
provider = OAuthClientProvider(
1051+
server_url="https://api.example.com/v1/mcp",
1052+
client_metadata=client_metadata,
1053+
storage=mock_storage,
1054+
redirect_handler=redirect_handler,
1055+
callback_handler=callback_handler,
1056+
)
1057+
1058+
# Test with 401 response without WWW-Authenticate header
1059+
init_response = httpx.Response(
1060+
status_code=401, headers={}, request=httpx.Request("GET", "https://api.example.com/v1/mcp")
1061+
)
1062+
1063+
# Build discovery URLs
1064+
discovery_urls = provider._build_protected_resource_discovery_urls(init_response)
1065+
1066+
# Should have path-based URL first, then root-based URL
1067+
assert len(discovery_urls) == 2
1068+
assert discovery_urls[0] == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
1069+
assert discovery_urls[1] == "https://api.example.com/.well-known/oauth-protected-resource"
1070+
1071+
@pytest.mark.anyio
1072+
async def test_root_based_fallback_after_path_based_404(
1073+
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
1074+
):
1075+
"""Test that client falls back to root-based URI when path-based returns 404."""
1076+
1077+
async def redirect_handler(url: str) -> None:
1078+
pass
1079+
1080+
async def callback_handler() -> tuple[str, str | None]:
1081+
return "test_auth_code", "test_state"
1082+
1083+
provider = OAuthClientProvider(
1084+
server_url="https://api.example.com/v1/mcp",
1085+
client_metadata=client_metadata,
1086+
storage=mock_storage,
1087+
redirect_handler=redirect_handler,
1088+
callback_handler=callback_handler,
1089+
)
1090+
1091+
# Ensure no tokens are stored
1092+
provider.context.current_tokens = None
1093+
provider.context.token_expiry_time = None
1094+
provider._initialized = True
1095+
1096+
# Mock client info to skip DCR
1097+
provider.context.client_info = OAuthClientInformationFull(
1098+
client_id="existing_client",
1099+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
1100+
)
1101+
1102+
# Create a test request
1103+
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
1104+
1105+
# Mock the auth flow
1106+
auth_flow = provider.async_auth_flow(test_request)
1107+
1108+
# First request should be the original request without auth header
1109+
request = await auth_flow.__anext__()
1110+
assert "Authorization" not in request.headers
1111+
1112+
# Send a 401 response without WWW-Authenticate header
1113+
response = httpx.Response(401, headers={}, request=test_request)
1114+
1115+
# Next request should be to discover protected resource metadata (path-based)
1116+
discovery_request_1 = await auth_flow.asend(response)
1117+
assert str(discovery_request_1.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
1118+
assert discovery_request_1.method == "GET"
1119+
1120+
# Send 404 response for path-based discovery
1121+
discovery_response_1 = httpx.Response(404, request=discovery_request_1)
1122+
1123+
# Next request should be to root-based well-known URI
1124+
discovery_request_2 = await auth_flow.asend(discovery_response_1)
1125+
assert str(discovery_request_2.url) == "https://api.example.com/.well-known/oauth-protected-resource"
1126+
assert discovery_request_2.method == "GET"
1127+
1128+
# Send successful discovery response
1129+
discovery_response_2 = httpx.Response(
1130+
200,
1131+
content=(
1132+
b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}'
1133+
),
1134+
request=discovery_request_2,
1135+
)
1136+
1137+
# Mock the rest of the OAuth flow
1138+
provider._perform_authorization = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier"))
1139+
1140+
# Next should be OAuth metadata discovery
1141+
oauth_metadata_request = await auth_flow.asend(discovery_response_2)
1142+
assert oauth_metadata_request.method == "GET"
1143+
1144+
# Complete the flow
1145+
oauth_metadata_response = httpx.Response(
1146+
200,
1147+
content=(
1148+
b'{"issuer": "https://auth.example.com", '
1149+
b'"authorization_endpoint": "https://auth.example.com/authorize", '
1150+
b'"token_endpoint": "https://auth.example.com/token"}'
1151+
),
1152+
request=oauth_metadata_request,
1153+
)
1154+
1155+
token_request = await auth_flow.asend(oauth_metadata_response)
1156+
token_response = httpx.Response(
1157+
200,
1158+
content=(
1159+
b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, '
1160+
b'"refresh_token": "new_refresh_token"}'
1161+
),
1162+
request=token_request,
1163+
)
1164+
1165+
final_request = await auth_flow.asend(token_response)
1166+
final_response = httpx.Response(200, request=final_request)
1167+
try:
1168+
await auth_flow.asend(final_response)
1169+
except StopAsyncIteration:
1170+
pass
1171+
1172+
@pytest.mark.anyio
1173+
async def test_www_authenticate_takes_priority_over_well_known(
1174+
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
1175+
):
1176+
"""Test that WWW-Authenticate header resource_metadata takes priority over well-known URIs."""
1177+
1178+
async def redirect_handler(url: str) -> None:
1179+
pass
1180+
1181+
async def callback_handler() -> tuple[str, str | None]:
1182+
return "test_auth_code", "test_state"
1183+
1184+
provider = OAuthClientProvider(
1185+
server_url="https://api.example.com/v1/mcp",
1186+
client_metadata=client_metadata,
1187+
storage=mock_storage,
1188+
redirect_handler=redirect_handler,
1189+
callback_handler=callback_handler,
1190+
)
1191+
1192+
# Test with 401 response with WWW-Authenticate header
1193+
init_response = httpx.Response(
1194+
status_code=401,
1195+
headers={
1196+
"WWW-Authenticate": 'Bearer resource_metadata="https://custom.example.com/.well-known/oauth-protected-resource"'
1197+
},
1198+
request=httpx.Request("GET", "https://api.example.com/v1/mcp"),
1199+
)
1200+
1201+
# Build discovery URLs
1202+
discovery_urls = provider._build_protected_resource_discovery_urls(init_response)
1203+
1204+
# Should have WWW-Authenticate URL first, then fallback URLs
1205+
assert len(discovery_urls) == 3
1206+
assert discovery_urls[0] == "https://custom.example.com/.well-known/oauth-protected-resource"
1207+
assert discovery_urls[1] == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
1208+
assert discovery_urls[2] == "https://api.example.com/.well-known/oauth-protected-resource"
1209+
1210+
10331211
class TestWWWAuthenticate:
10341212
"""Test WWW-Authenticate header parsing functionality."""
10351213

0 commit comments

Comments
 (0)