|
18 | 18 | from pydantic import AnyHttpUrl, AnyUrl |
19 | 19 |
|
20 | 20 | from mcp import MCPError |
21 | | -from mcp.client.auth import OAuthClientProvider |
| 21 | +from mcp.client.auth import OAuthClientProvider, OAuthFlowError |
22 | 22 | from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider |
23 | 23 | from mcp.server import Server, ServerRequestContext |
24 | | -from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata |
| 24 | +from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata, OAuthToken |
25 | 25 | from tests.interaction._connect import BASE_URL |
26 | 26 | from tests.interaction._requirements import requirement |
27 | 27 | from tests.interaction.auth._harness import ( |
28 | 28 | REDIRECT_URI, |
29 | 29 | AppShim, |
| 30 | + HeadlessOAuth, |
30 | 31 | InMemoryTokenStorage, |
31 | 32 | RecordedRequest, |
32 | 33 | auth_settings, |
@@ -125,6 +126,17 @@ def restarted_headless_provider(storage: InMemoryTokenStorage) -> OAuthClientPro |
125 | 126 | return OAuthClientProvider(server_url=f"{BASE_URL}/mcp", client_metadata=oauth_client_metadata(), storage=storage) |
126 | 127 |
|
127 | 128 |
|
| 129 | +def restarted_interactive_provider(storage: InMemoryTokenStorage, headless: HeadlessOAuth) -> OAuthClientProvider: |
| 130 | + """A provider as a second process with a browser available constructs it: same storage, fresh state.""" |
| 131 | + return OAuthClientProvider( |
| 132 | + server_url=f"{BASE_URL}/mcp", |
| 133 | + client_metadata=oauth_client_metadata(), |
| 134 | + storage=storage, |
| 135 | + redirect_handler=headless.redirect_handler, |
| 136 | + callback_handler=headless.callback_handler, |
| 137 | + ) |
| 138 | + |
| 139 | + |
128 | 140 | @requirement("client-auth:refresh:transparent") |
129 | 141 | async def test_an_expired_access_token_is_transparently_refreshed_before_the_next_request() -> None: |
130 | 142 | """An access token the client considers expired is refreshed and the new bearer is used. |
@@ -466,6 +478,154 @@ async def test_a_restarted_client_refreshes_at_the_token_endpoint_advertised_und |
466 | 478 | assert not any(r.path.endswith("/authorize") for r in recorded) |
467 | 479 |
|
468 | 480 |
|
| 481 | +@requirement("client-auth:invalid-grant-clears-tokens") |
| 482 | +async def test_a_refresh_the_server_rejects_on_the_401_path_falls_back_to_authorization() -> None: |
| 483 | + """When the 401 path's refresh is rejected, the flow runs the full authorization instead of giving up. |
| 484 | +
|
| 485 | + Second process with a browser; the harness denies the one refresh with `invalid_grant`. The |
| 486 | + recording proves the refresh was tried first, then exactly one authorize and code exchange, |
| 487 | + with no re-registration. |
| 488 | + """ |
| 489 | + provider = InMemoryAuthorizationServerProvider(fail_next_refresh=True) |
| 490 | + storage = InMemoryTokenStorage() |
| 491 | + with anyio.fail_after(5): |
| 492 | + provider.expire_access_token(await first_process_login(provider, storage)) |
| 493 | + |
| 494 | + recorded, on_request = record_requests() |
| 495 | + headless = HeadlessOAuth() |
| 496 | + server = Server("guarded", on_list_tools=list_tools) |
| 497 | + with anyio.fail_after(5): |
| 498 | + async with connect_with_oauth( |
| 499 | + server, |
| 500 | + provider=provider, |
| 501 | + auth=restarted_interactive_provider(storage, headless), |
| 502 | + headless=headless, |
| 503 | + on_request=on_request, |
| 504 | + ) as (client, _): |
| 505 | + result = await client.list_tools() |
| 506 | + |
| 507 | + assert result.tools[0].name == "echo" |
| 508 | + assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == snapshot( |
| 509 | + ["refresh_token", "authorization_code"] |
| 510 | + ) |
| 511 | + counts = path_counts(recorded) |
| 512 | + assert counts[("GET", "/authorize")] == 1 |
| 513 | + assert counts[("POST", "/register")] == 0 |
| 514 | + |
| 515 | + |
| 516 | +@requirement("client-auth:refresh:on-401") |
| 517 | +async def test_a_headless_client_whose_refresh_failed_retries_it_from_storage_on_the_next_connection() -> None: |
| 518 | + """A failed refresh does not wedge a long-lived headless provider: the next connection reloads and refreshes. |
| 519 | +
|
| 520 | + The harness denies the first refresh with `invalid_grant` but leaves the refresh token valid, |
| 521 | + standing in for a transient token-endpoint failure. The first connect raises (no browser to |
| 522 | + fall back to); the second, through the same provider instance, refreshes and succeeds. |
| 523 | + """ |
| 524 | + provider = InMemoryAuthorizationServerProvider(fail_next_refresh=True) |
| 525 | + storage = InMemoryTokenStorage() |
| 526 | + with anyio.fail_after(5): |
| 527 | + provider.expire_access_token(await first_process_login(provider, storage)) |
| 528 | + daemon = restarted_headless_provider(storage) |
| 529 | + |
| 530 | + with anyio.fail_after(5): |
| 531 | + with pytest.RaisesGroup(pytest.RaisesExc(OAuthFlowError), flatten_subgroups=True): |
| 532 | + await connect_with_oauth( |
| 533 | + Server("guarded", on_list_tools=list_tools), provider=provider, auth=daemon |
| 534 | + ).__aenter__() |
| 535 | + |
| 536 | + recorded, on_request = record_requests() |
| 537 | + with anyio.fail_after(5): |
| 538 | + async with connect_with_oauth( |
| 539 | + Server("guarded", on_list_tools=list_tools), provider=provider, auth=daemon, on_request=on_request |
| 540 | + ) as (client, _): |
| 541 | + result = await client.list_tools() |
| 542 | + |
| 543 | + assert result.tools[0].name == "echo" |
| 544 | + assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == ["refresh_token"] |
| 545 | + assert find(recorded, "GET", "/authorize") == [] |
| 546 | + |
| 547 | + |
| 548 | +@requirement("client-auth:refresh:on-401") |
| 549 | +async def test_a_fresh_registration_does_not_present_tokens_left_from_a_previous_client() -> None: |
| 550 | + """Tokens found in storage without their registration are not refreshed under a newly registered client. |
| 551 | +
|
| 552 | + The second process finds tokens but no `client_info` (lost, or never persisted), so the 401 |
| 553 | + path registers a new client; the refresh token belonged to the old one and is dropped rather |
| 554 | + than presented, and the flow authorizes once. RFC 6749 §6 binds refresh tokens to their client. |
| 555 | + """ |
| 556 | + provider = InMemoryAuthorizationServerProvider() |
| 557 | + storage = InMemoryTokenStorage() |
| 558 | + with anyio.fail_after(5): |
| 559 | + provider.expire_access_token(await first_process_login(provider, storage)) |
| 560 | + storage.client_info = None |
| 561 | + |
| 562 | + recorded, on_request = record_requests() |
| 563 | + headless = HeadlessOAuth() |
| 564 | + server = Server("guarded", on_list_tools=list_tools) |
| 565 | + with anyio.fail_after(5): |
| 566 | + async with connect_with_oauth( |
| 567 | + server, |
| 568 | + provider=provider, |
| 569 | + auth=restarted_interactive_provider(storage, headless), |
| 570 | + headless=headless, |
| 571 | + on_request=on_request, |
| 572 | + ) as (client, _): |
| 573 | + result = await client.list_tools() |
| 574 | + |
| 575 | + assert result.tools[0].name == "echo" |
| 576 | + assert [(r.method, r.path) for r in recorded[:6]] == snapshot( |
| 577 | + [ |
| 578 | + ("POST", "/mcp"), |
| 579 | + ("GET", "/.well-known/oauth-protected-resource/mcp"), |
| 580 | + ("GET", "/.well-known/oauth-authorization-server"), |
| 581 | + ("POST", "/register"), |
| 582 | + ("GET", "/authorize"), |
| 583 | + ("POST", "/token"), |
| 584 | + ] |
| 585 | + ) |
| 586 | + assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == ["authorization_code"] |
| 587 | + |
| 588 | + |
| 589 | +@requirement("client-auth:as-binding") |
| 590 | +async def test_a_cimd_client_keeps_its_id_but_drops_its_tokens_when_the_authorization_server_changes() -> None: |
| 591 | + """A CIMD registration is portable across authorization servers; the tokens issued under it are not. |
| 592 | +
|
| 593 | + Storage holds a CIMD record stamped with a previous issuer and a refresh token minted there. |
| 594 | + On the 401 the discovered issuer differs, so the flow keeps the URL client_id, re-stamps it, |
| 595 | + and authorizes afresh instead of presenting the old refresh token to the new server (SEP-2352; |
| 596 | + the TypeScript SDK discards tokens on the same mismatch). |
| 597 | + """ |
| 598 | + recorded, on_request = record_requests() |
| 599 | + provider = InMemoryAuthorizationServerProvider() |
| 600 | + seeded_client(provider, client_id=CIMD_URL) |
| 601 | + stale = OAuthClientInformationFull( |
| 602 | + client_id=CIMD_URL, |
| 603 | + token_endpoint_auth_method="none", |
| 604 | + redirect_uris=[AnyUrl(REDIRECT_URI)], |
| 605 | + issuer="https://old-as.example.com", |
| 606 | + ) |
| 607 | + storage = InMemoryTokenStorage(client_info=stale) |
| 608 | + storage.tokens = OAuthToken(access_token="issued-by-old-as", refresh_token="refresh-from-old-as", expires_in=3600) |
| 609 | + server = Server("guarded", on_list_tools=list_tools) |
| 610 | + |
| 611 | + with anyio.fail_after(5): |
| 612 | + async with connect_with_oauth( |
| 613 | + server, |
| 614 | + provider=provider, |
| 615 | + storage=storage, |
| 616 | + client_metadata_url=CIMD_URL, |
| 617 | + app_shim=shim(serve={ASM_PATH: cimd_supported_metadata()}), |
| 618 | + on_request=on_request, |
| 619 | + ) as (client, _): |
| 620 | + await client.list_tools() |
| 621 | + |
| 622 | + assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == ["authorization_code"] |
| 623 | + assert all(b"refresh-from-old-as" not in r.content for r in recorded) |
| 624 | + assert path_counts(recorded)[("POST", "/register")] == 0 |
| 625 | + assert storage.client_info is not None |
| 626 | + assert (storage.client_info.client_id, storage.client_info.issuer) == (CIMD_URL, f"{BASE_URL}/") |
| 627 | + |
| 628 | + |
469 | 629 | @requirement("client-auth:client-credentials") |
470 | 630 | async def test_client_credentials_provider_obtains_a_token_without_an_authorize_step() -> None: |
471 | 631 | """The client-credentials provider connects with no authorize step and a `client_credentials` grant. |
|
0 commit comments