@@ -252,6 +252,72 @@ async def test_exchange_token_client_credentials(self, mock_storage: MockTokenSt
252252 assert "scope=read write" in content
253253 assert "resource=https://api.example.com/v1/mcp" in content
254254
255+ @pytest .mark .anyio
256+ async def test_exchange_token_client_secret_post_includes_client_id (self , mock_storage : MockTokenStorage ):
257+ """Test that client_secret_post includes both client_id and client_secret in body (RFC 6749 §2.3.1)."""
258+ provider = ClientCredentialsOAuthProvider (
259+ server_url = "https://api.example.com/v1/mcp" ,
260+ storage = mock_storage ,
261+ client_id = "test-client-id" ,
262+ client_secret = "test-client-secret" ,
263+ token_endpoint_auth_method = "client_secret_post" ,
264+ scopes = "read write" ,
265+ )
266+ await provider ._initialize ()
267+ provider .context .oauth_metadata = OAuthMetadata (
268+ issuer = AnyHttpUrl ("https://api.example.com" ),
269+ authorization_endpoint = AnyHttpUrl ("https://api.example.com/authorize" ),
270+ token_endpoint = AnyHttpUrl ("https://api.example.com/token" ),
271+ )
272+ provider .context .protocol_version = "2025-06-18"
273+
274+ request = await provider ._perform_authorization ()
275+
276+ content = urllib .parse .unquote_plus (request .content .decode ())
277+ assert "grant_type=client_credentials" in content
278+ assert "client_id=test-client-id" in content
279+ assert "client_secret=test-client-secret" in content
280+ # Should NOT have Basic auth header
281+ assert "Authorization" not in request .headers
282+
283+ @pytest .mark .anyio
284+ async def test_exchange_token_client_secret_post_without_client_id (self , mock_storage : MockTokenStorage ):
285+ """Test client_secret_post skips body credentials when client_id is None."""
286+ provider = ClientCredentialsOAuthProvider (
287+ server_url = "https://api.example.com/v1/mcp" ,
288+ storage = mock_storage ,
289+ client_id = "placeholder" ,
290+ client_secret = "test-client-secret" ,
291+ token_endpoint_auth_method = "client_secret_post" ,
292+ scopes = "read write" ,
293+ )
294+ await provider ._initialize ()
295+ provider .context .oauth_metadata = OAuthMetadata (
296+ issuer = AnyHttpUrl ("https://api.example.com" ),
297+ authorization_endpoint = AnyHttpUrl ("https://api.example.com/authorize" ),
298+ token_endpoint = AnyHttpUrl ("https://api.example.com/token" ),
299+ )
300+ provider .context .protocol_version = "2025-06-18"
301+ # Override client_info to have client_id=None (edge case)
302+ provider .context .client_info = OAuthClientInformationFull (
303+ redirect_uris = None ,
304+ client_id = None ,
305+ client_secret = "test-client-secret" ,
306+ grant_types = ["client_credentials" ],
307+ token_endpoint_auth_method = "client_secret_post" ,
308+ scope = "read write" ,
309+ )
310+
311+ request = await provider ._perform_authorization ()
312+
313+ content = urllib .parse .unquote_plus (request .content .decode ())
314+ assert "grant_type=client_credentials" in content
315+ # Neither client_id nor client_secret should be in body since client_id is None
316+ # (RFC 6749 §2.3.1 requires both for client_secret_post)
317+ assert "client_id=" not in content
318+ assert "client_secret=" not in content
319+ assert "Authorization" not in request .headers
320+
255321 @pytest .mark .anyio
256322 async def test_exchange_token_without_scopes (self , mock_storage : MockTokenStorage ):
257323 """Test token exchange without scopes."""
0 commit comments