|
18 | 18 | from pydantic import AnyHttpUrl, AnyUrl |
19 | 19 |
|
20 | 20 | from mcp import MCPError |
| 21 | +from mcp.client.auth import OAuthClientProvider |
21 | 22 | from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider |
22 | 23 | from mcp.server import Server, ServerRequestContext |
23 | 24 | from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata |
24 | 25 | from tests.interaction._connect import BASE_URL |
25 | 26 | from tests.interaction._requirements import requirement |
26 | 27 | from tests.interaction.auth._harness import ( |
27 | 28 | REDIRECT_URI, |
| 29 | + AppShim, |
28 | 30 | InMemoryTokenStorage, |
29 | 31 | RecordedRequest, |
30 | 32 | auth_settings, |
31 | 33 | connect_with_oauth, |
32 | 34 | m2m_token_shim, |
33 | 35 | metadata_body, |
| 36 | + oauth_client_metadata, |
| 37 | + path_prefixed_as_shim, |
34 | 38 | record_requests, |
35 | 39 | shim, |
36 | 40 | step_up_shim, |
@@ -98,6 +102,29 @@ def seeded_client(provider: InMemoryAuthorizationServerProvider, **kwargs: objec |
98 | 102 | return info |
99 | 103 |
|
100 | 104 |
|
| 105 | +async def first_process_login( |
| 106 | + provider: InMemoryAuthorizationServerProvider, storage: InMemoryTokenStorage, *, app_shim: AppShim | None = None |
| 107 | +) -> str: |
| 108 | + """Run one interactive connect so `storage` holds what a first process leaves behind; return its access token. |
| 109 | +
|
| 110 | + The restart tests then build a fresh `OAuthClientProvider` over the same storage, as a second |
| 111 | + process would, so the registration and tokens carry exactly what the SDK persists. |
| 112 | + """ |
| 113 | + server = Server("guarded", on_list_tools=list_tools) |
| 114 | + async with connect_with_oauth(server, provider=provider, storage=storage, app_shim=app_shim) as (client, _): |
| 115 | + await client.list_tools() |
| 116 | + assert storage.tokens is not None and storage.tokens.refresh_token is not None |
| 117 | + return storage.tokens.access_token |
| 118 | + |
| 119 | + |
| 120 | +def restarted_headless_provider(storage: InMemoryTokenStorage) -> OAuthClientProvider: |
| 121 | + """A provider as a second, headless process constructs it: same storage, fresh state, no handlers. |
| 122 | +
|
| 123 | + Reaching the interactive step raises rather than opening a browser the scenario says is absent. |
| 124 | + """ |
| 125 | + return OAuthClientProvider(server_url=f"{BASE_URL}/mcp", client_metadata=oauth_client_metadata(), storage=storage) |
| 126 | + |
| 127 | + |
101 | 128 | @requirement("client-auth:refresh:transparent") |
102 | 129 | async def test_an_expired_access_token_is_transparently_refreshed_before_the_next_request() -> None: |
103 | 130 | """An access token the client considers expired is refreshed and the new bearer is used. |
@@ -354,6 +381,91 @@ async def test_a_failed_refresh_clears_stored_tokens_and_restarts_the_full_flow( |
354 | 381 | assert storage.tokens.access_token in provider.access_tokens |
355 | 382 |
|
356 | 383 |
|
| 384 | +@requirement("client-auth:refresh:on-401") |
| 385 | +async def test_a_restarted_client_answers_a_401_with_its_stored_refresh_token() -> None: |
| 386 | + """A second process holding only persisted tokens and registration refreshes on 401 instead of re-authorizing. |
| 387 | +
|
| 388 | + Steps: (1) a first process logs in and its storage keeps the registration and a refresh token; |
| 389 | + (2) the server-side access token lapses; (3) a fresh provider over the same storage, with no |
| 390 | + browser, connects. The recording proves the stale bearer drew a 401, discovery ran, one |
| 391 | + `refresh_token` grant followed, and neither `/authorize` nor `/register` was touched. |
| 392 | + SDK behaviour per RFC 6749 §1.5; regression bar for #3250 / #1318. |
| 393 | + """ |
| 394 | + provider = InMemoryAuthorizationServerProvider() |
| 395 | + storage = InMemoryTokenStorage() |
| 396 | + with anyio.fail_after(5): |
| 397 | + stale_access_token = await first_process_login(provider, storage) |
| 398 | + provider.expire_access_token(stale_access_token) |
| 399 | + |
| 400 | + recorded, on_request = record_requests() |
| 401 | + server = Server("guarded", on_list_tools=list_tools) |
| 402 | + with anyio.fail_after(5): |
| 403 | + async with connect_with_oauth( |
| 404 | + server, provider=provider, auth=restarted_headless_provider(storage), on_request=on_request |
| 405 | + ) as (client, _): |
| 406 | + result = await client.list_tools() |
| 407 | + |
| 408 | + assert result.tools[0].name == "echo" |
| 409 | + assert [(r.method, r.path) for r in recorded[:5]] == snapshot( |
| 410 | + [ |
| 411 | + ("POST", "/mcp"), |
| 412 | + ("GET", "/.well-known/oauth-protected-resource/mcp"), |
| 413 | + ("GET", "/.well-known/oauth-authorization-server"), |
| 414 | + ("POST", "/token"), |
| 415 | + ("POST", "/mcp"), |
| 416 | + ] |
| 417 | + ) |
| 418 | + assert recorded[0].headers["authorization"] == f"Bearer {stale_access_token}" |
| 419 | + assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == ["refresh_token"] |
| 420 | + assert find(recorded, "GET", "/authorize") == [] and find(recorded, "POST", "/register") == [] |
| 421 | + assert storage.tokens is not None and storage.tokens.access_token != stale_access_token |
| 422 | + assert storage.tokens.access_token in provider.access_tokens |
| 423 | + |
| 424 | + |
| 425 | +@requirement("client-auth:refresh:discovered-endpoint") |
| 426 | +async def test_a_restarted_client_refreshes_at_the_token_endpoint_advertised_under_a_path() -> None: |
| 427 | + """Against an authorization server under `/oauth2/v1`, a second process refreshes at `/oauth2/v1/token`. |
| 428 | +
|
| 429 | + The bare `/token` 404s here. Nothing is discovered yet in the second process, so no refresh is |
| 430 | + attempted before the request; the 401 drives discovery and the single refresh POST goes to the |
| 431 | + advertised endpoint. Regression bar for #3240, where the guessed `{origin}/token` 404ed and the |
| 432 | + refresh token was discarded. |
| 433 | + """ |
| 434 | + prefix = "/oauth2/v1" |
| 435 | + provider = InMemoryAuthorizationServerProvider(issuer=f"{BASE_URL}{prefix}") |
| 436 | + storage = InMemoryTokenStorage() |
| 437 | + app_shim = path_prefixed_as_shim(prefix) |
| 438 | + with anyio.fail_after(5): |
| 439 | + stale_access_token = await first_process_login(provider, storage, app_shim=app_shim) |
| 440 | + provider.expire_access_token(stale_access_token) |
| 441 | + |
| 442 | + recorded, on_request = record_requests() |
| 443 | + server = Server("guarded", on_list_tools=list_tools) |
| 444 | + with anyio.fail_after(5): |
| 445 | + async with connect_with_oauth( |
| 446 | + server, |
| 447 | + provider=provider, |
| 448 | + auth=restarted_headless_provider(storage), |
| 449 | + app_shim=app_shim, |
| 450 | + on_request=on_request, |
| 451 | + ) as (client, _): |
| 452 | + result = await client.list_tools() |
| 453 | + |
| 454 | + assert result.tools[0].name == "echo" |
| 455 | + assert [(r.method, r.path) for r in recorded[:5]] == snapshot( |
| 456 | + [ |
| 457 | + ("POST", "/mcp"), |
| 458 | + ("GET", "/.well-known/oauth-protected-resource/mcp"), |
| 459 | + ("GET", "/.well-known/oauth-authorization-server/oauth2/v1"), |
| 460 | + ("POST", "/oauth2/v1/token"), |
| 461 | + ("POST", "/mcp"), |
| 462 | + ] |
| 463 | + ) |
| 464 | + token_posts = [r for r in recorded if r.method == "POST" and r.path.endswith("/token")] |
| 465 | + assert [(r.path, form_body(r)["grant_type"]) for r in token_posts] == [("/oauth2/v1/token", "refresh_token")] |
| 466 | + assert not any(r.path.endswith("/authorize") for r in recorded) |
| 467 | + |
| 468 | + |
357 | 469 | @requirement("client-auth:client-credentials") |
358 | 470 | async def test_client_credentials_provider_obtains_a_token_without_an_authorize_step() -> None: |
359 | 471 | """The client-credentials provider connects with no authorize step and a `client_credentials` grant. |
|
0 commit comments