fastmcp/tests/client/test_oauth_callback_race.py
Jeremiah Lowin 67e8448389
[codex] Add OAuthProxy RFC 9207 issuer responses (#4438)
* Add OAuthProxy issuer response parameter

* Cover OAuthProxy issuer error redirects

* Relax host origin guard defaults (#4439)

* Use exact issuer in authorize errors

* Restore HTTP host guard compatibility (#4472)

* Hugging Face Auth Integration (#4385)

Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>

* Docs: add v3.4.4 changelog entries (#4473)

* Explain unnormalized issuer; cover consent-denial path base_url

* Revert "Merge remote-tracking branch 'origin/release/3.x' into codex/oauth-proxy-rfc9207-issuer"

This reverts commit 9e34b1686c, reversing
changes made to 640dc60fe0.

* Preserve callback query bytes when appending iss/code/state params

add_query_params previously decoded the existing query with parse_qsl
and re-encoded it, mutating opaque or signed query strings (a valueless
?flag became ?flag=, non-UTF-8 percent-encoded bytes got replaced).
Append the newly-encoded params to the existing query string instead of
round-tripping it through parse/encode.

Also fixes a stray bare `httpx` reference in a test that should use
httpx2 following the SDK v2 migration.

* Attach RFC 9207 iss to authorize() success redirects too

AuthorizationHandler only added iss to error redirects from the SDK's
base handler, not to code redirects returned directly by authorize()
overrides that bypass consent/upstream (as GitHub's mocked test does).
Since metadata now unconditionally advertises
authorization_response_iss_parameter_supported, any client-facing
redirect missing iss hard-fails RFC 9207-aware clients.

Also fixes HeadlessOAuth, which parsed code/state from the redirect
but silently dropped iss, so the same regression would have masked
itself across every other provider integration test too.

* Carry RFC 9207 iss through the production OAuth callback path

OAuthProxy advertises authorization_response_iss_parameter_supported and
sends iss on every authorization redirect, but the client's production
callback chain (CallbackResponse -> OAuthCallbackResult -> OAuth.callback_handler)
had no iss field, so it was silently dropped and the SDK's
validate_authorization_response_iss rejected the callback. HeadlessOAuth
already carried iss through, which is why CI stayed green while real
clients failed.

Add iss to CallbackResponse and OAuthCallbackResult, thread it through
store_result_once for both success and error branches, and pass it into
AuthorizationCodeResult in OAuth.callback_handler.

* Don't duplicate iss when a provider redirect already carries one

* Consolidate RFC 9207 iss handling into a single redirect helper

Every client-facing authorization redirect must carry exactly one iss.
That invariant was being enforced by hand at five separate call sites,
each building its own params dict -- which is how the success-redirect
path shipped without iss in the first place, and how a registered
redirect_uri that already carries its own iss could end up duplicated.
Route all five sites through build_client_redirect(), which owns the
idempotent replace-or-append behavior so no caller can get it wrong.

---------

Co-authored-by: shaun smith <1936278+evalstate@users.noreply.github.com>
2026-07-19 09:52:43 -04:00

133 lines
4.1 KiB
Python

import anyio
import httpx2
from fastmcp.client.oauth_callback import (
OAuthCallbackResult,
create_oauth_callback_server,
)
from fastmcp.utilities.http import find_available_port
async def test_oauth_callback_result_ignores_subsequent_callbacks():
"""Only the first callback should be captured in shared OAuth callback state."""
port = find_available_port()
result = OAuthCallbackResult()
result_ready = anyio.Event()
server = create_oauth_callback_server(
port=port,
result_container=result,
result_ready=result_ready,
)
async with anyio.create_task_group() as tg:
tg.start_soon(server.serve)
await anyio.sleep(0.05)
async with httpx2.AsyncClient() as client:
first = await client.get(
f"http://127.0.0.1:{port}/callback?code=good&state=s1"
)
assert first.status_code == 200
await result_ready.wait()
second = await client.get(
f"http://127.0.0.1:{port}/callback?code=evil&state=s2"
)
assert second.status_code == 200
assert result.error is None
assert result.code == "good"
assert result.state == "s1"
tg.cancel_scope.cancel()
def test_oauth_callback_server_uses_configured_host():
server = create_oauth_callback_server(port=find_available_port(), host="localhost")
assert server.config.host == "localhost"
async def test_oauth_callback_result_captures_iss():
"""RFC 9207: the `iss` query parameter must survive from the raw callback
request through to `OAuthCallbackResult`, the same as `code` and `state`.
OAuthProxy advertises `authorization_response_iss_parameter_supported` and
includes `iss` on every authorization redirect. If the callback server's
query-parsing chain (CallbackResponse.from_dict -> store_result_once ->
OAuthCallbackResult) drops it, the MCP SDK's `validate_authorization_response_iss`
rejects an otherwise-successful callback.
"""
port = find_available_port()
result = OAuthCallbackResult()
result_ready = anyio.Event()
server = create_oauth_callback_server(
port=port,
result_container=result,
result_ready=result_ready,
)
async with anyio.create_task_group() as tg:
tg.start_soon(server.serve)
await anyio.sleep(0.05)
async with httpx2.AsyncClient() as client:
response = await client.get(
f"http://127.0.0.1:{port}/callback",
params={
"code": "good",
"state": "s1",
"iss": "https://issuer.example.com",
},
)
assert response.status_code == 200
await result_ready.wait()
assert result.error is None
assert result.code == "good"
assert result.state == "s1"
assert result.iss == "https://issuer.example.com"
tg.cancel_scope.cancel()
async def test_oauth_callback_result_captures_iss_on_error():
"""RFC 9207 applies to error redirects too -- the server emits `iss` on
them, so the callback server must not silently drop it while building the
error result.
"""
port = find_available_port()
result = OAuthCallbackResult()
result_ready = anyio.Event()
server = create_oauth_callback_server(
port=port,
result_container=result,
result_ready=result_ready,
)
async with anyio.create_task_group() as tg:
tg.start_soon(server.serve)
await anyio.sleep(0.05)
async with httpx2.AsyncClient() as client:
response = await client.get(
f"http://127.0.0.1:{port}/callback",
params={
"error": "access_denied",
"state": "s1",
"iss": "https://issuer.example.com",
},
)
assert response.status_code == 400
await result_ready.wait()
assert result.error is not None
assert result.iss == "https://issuer.example.com"
tg.cancel_scope.cancel()