1111the exchange path produces a readable diff of what fired.
1212"""
1313
14+ import base64
15+ from typing import Literal
1416from urllib .parse import parse_qsl
1517
1618import 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" )
141201async 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