Skip to content

Commit b94851b

Browse files
committed
Strengthen token-exchange interaction tests per review
- Negative tests assert the specific OAuth error (unsupported_grant_type when disabled, invalid_grant for a rejected subject token) instead of any token-endpoint failure. - Reorder positive tests recording-first, matching the suite's stated debugging style. - Add a full-stack interaction test for a confidential (client_secret_basic) token-exchange client, exercising the TokenExchangeOAuthProvider / ClientAuthenticator seam.
1 parent 55c2413 commit b94851b

1 file changed

Lines changed: 74 additions & 8 deletions

File tree

tests/interaction/auth/test_token_exchange.py

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
the exchange path produces a readable diff of what fired.
1212
"""
1313

14+
import base64
15+
from typing import Literal
1416
from urllib.parse import parse_qsl
1517

1618
import anyio
@@ -53,17 +55,24 @@ def form_body(request: RecordedRequest) -> dict[str, str]:
5355
return dict(parse_qsl(request.content.decode()))
5456

5557

56-
def preregister_token_exchange_client(provider: InMemoryAuthorizationServerProvider) -> None:
57-
"""Seed a pre-registered public client allowed to use the token-exchange grant.
58+
def preregister_token_exchange_client(
59+
provider: InMemoryAuthorizationServerProvider,
60+
*,
61+
client_secret: str | None = None,
62+
token_endpoint_auth_method: Literal["none", "client_secret_post", "client_secret_basic"] = "none",
63+
) -> None:
64+
"""Seed a pre-registered client allowed to use the token-exchange grant.
5865
5966
Token-exchange clients are provisioned out of band (no DCR), so the server already knows the
60-
client_id; `TokenExchangeOAuthProvider` presents the same id without registering.
67+
client_id; `TokenExchangeOAuthProvider` presents the same id without registering. Defaults to a
68+
public client; pass a secret + auth method for a confidential one.
6169
"""
6270
provider.clients[CLIENT_ID] = OAuthClientInformationFull(
6371
client_id=CLIENT_ID,
72+
client_secret=client_secret,
6473
redirect_uris=None,
6574
grant_types=[TOKEN_EXCHANGE_GRANT_TYPE],
66-
token_endpoint_auth_method="none",
75+
token_endpoint_auth_method=token_endpoint_auth_method,
6776
scope="mcp",
6877
)
6978

@@ -114,7 +123,7 @@ async def test_token_exchange_obtains_a_token_and_authorizes_the_request() -> No
114123
) as (client, headless):
115124
result = await client.list_tools()
116125

117-
assert result.tools[0].name == "echo"
126+
# Recording-first: assert what fired before the call result.
118127
assert headless.authorize_url is None
119128
assert find(recorded, "GET", "/authorize") == []
120129
assert find(recorded, "POST", "/register") == []
@@ -133,10 +142,61 @@ async def test_token_exchange_obtains_a_token_and_authorizes_the_request() -> No
133142
}
134143
)
135144

145+
assert result.tools[0].name == "echo"
136146
assert storage.tokens is not None
137147
assert storage.tokens.access_token in provider.access_tokens
138148

139149

150+
@requirement("client-auth:token-exchange")
151+
async def test_confidential_token_exchange_client_authenticates_with_basic() -> None:
152+
"""A confidential token-exchange client authenticates to the real token endpoint with HTTP Basic.
153+
154+
Exercises the seam between `TokenExchangeOAuthProvider` and the server's `ClientAuthenticator`:
155+
the pre-registered client carries a secret and `client_secret_basic`, the recorded /token request
156+
carries the Basic credentials (not a body secret), and the exchange still succeeds.
157+
"""
158+
recorded, on_request = record_requests()
159+
provider = InMemoryAuthorizationServerProvider()
160+
preregister_token_exchange_client(
161+
provider, client_secret="te-secret", token_endpoint_auth_method="client_secret_basic"
162+
)
163+
server = Server("guarded", on_list_tools=list_tools)
164+
storage = InMemoryTokenStorage()
165+
166+
async def subject_token_provider(audience: str) -> str:
167+
return VALID_SUBJECT_TOKEN
168+
169+
auth = TokenExchangeOAuthProvider(
170+
server_url=f"{BASE_URL}/mcp",
171+
storage=storage,
172+
client_id=CLIENT_ID,
173+
subject_token_provider=subject_token_provider,
174+
scopes="mcp",
175+
client_secret="te-secret",
176+
token_endpoint_auth_method="client_secret_basic",
177+
)
178+
179+
with anyio.fail_after(5):
180+
async with connect_with_oauth(
181+
server,
182+
provider=provider,
183+
settings=auth_settings(token_exchange_enabled=True),
184+
auth=auth,
185+
on_request=on_request,
186+
) as (client, _):
187+
result = await client.list_tools()
188+
189+
# Recording-first: the client authenticated with Basic, not a body secret.
190+
[token_req] = find(recorded, "POST", "/token")
191+
body = form_body(token_req)
192+
decoded = base64.b64decode(token_req.headers["authorization"].removeprefix("Basic ")).decode()
193+
assert decoded == f"{CLIENT_ID}:te-secret"
194+
assert "client_secret" not in body
195+
assert body["grant_type"] == TOKEN_EXCHANGE_GRANT_TYPE
196+
assert result.tools[0].name == "echo"
197+
assert storage.tokens is not None
198+
199+
140200
@requirement("client-auth:token-exchange")
141201
async def test_token_exchange_adopts_server_scopes_when_client_requests_none() -> None:
142202
"""A client built with no scope adopts the AS-advertised scope during discovery and sends it.
@@ -162,11 +222,12 @@ async def test_token_exchange_adopts_server_scopes_when_client_requests_none() -
162222
) as (client, _):
163223
result = await client.list_tools()
164224

165-
assert result.tools[0].name == "echo"
225+
# Recording-first: assert the sent scope before the call result.
166226
[token_req] = find(recorded, "POST", "/token")
167227
assert form_body(token_req)["scope"] == "mcp"
168228
assert provider.last_exchange_params is not None
169229
assert provider.last_exchange_params.scopes == ["mcp"]
230+
assert result.tools[0].name == "echo"
170231
assert storage.tokens is not None
171232
assert storage.tokens.scope == "mcp"
172233

@@ -214,7 +275,9 @@ async def test_token_exchange_is_rejected_when_disabled_on_the_server() -> None:
214275

215276
with anyio.fail_after(5):
216277
with pytest.RaisesGroup(
217-
pytest.RaisesExc(OAuthTokenError, match="^Token exchange failed"), flatten_subgroups=True
278+
# The 400 carries unsupported_grant_type specifically, not just any token failure.
279+
pytest.RaisesExc(OAuthTokenError, match=r"Token exchange failed \(400\):.*unsupported_grant_type"),
280+
flatten_subgroups=True,
218281
):
219282
await connect_with_oauth(
220283
server,
@@ -237,7 +300,9 @@ async def test_an_unaccepted_subject_token_aborts_the_flow() -> None:
237300

238301
with anyio.fail_after(5):
239302
with pytest.RaisesGroup(
240-
pytest.RaisesExc(OAuthTokenError, match="^Token exchange failed"), flatten_subgroups=True
303+
# The provider rejected the subject token with invalid_grant specifically.
304+
pytest.RaisesExc(OAuthTokenError, match=r"Token exchange failed \(400\):.*invalid_grant"),
305+
flatten_subgroups=True,
241306
):
242307
await connect_with_oauth(
243308
server,
@@ -247,6 +312,7 @@ async def test_an_unaccepted_subject_token_aborts_the_flow() -> None:
247312
).__aenter__()
248313

249314
assert provider.last_exchange_params is not None
315+
assert provider.last_exchange_params.subject_token == "forged-id-jag"
250316
assert storage.tokens is None
251317

252318

0 commit comments

Comments
 (0)