1212
1313import httpx
1414import pytest
15- from pydantic import AnyHttpUrl
15+ from pydantic import AnyHttpUrl , AnyUrl
1616from starlette .applications import Starlette
1717
1818from mcp .server .auth .provider import (
@@ -357,7 +357,7 @@ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
357357 assert metadata ["revocation_endpoint" ] == "https://auth.example.com/revoke"
358358 assert metadata ["response_types_supported" ] == ["code" ]
359359 assert metadata ["code_challenge_methods_supported" ] == ["S256" ]
360- assert metadata ["token_endpoint_auth_methods_supported" ] == ["client_secret_post" ]
360+ assert metadata ["token_endpoint_auth_methods_supported" ] == ["client_secret_post" , "client_secret_basic" ]
361361 assert metadata ["grant_types_supported" ] == [
362362 "authorization_code" ,
363363 "refresh_token" ,
@@ -376,8 +376,8 @@ async def test_token_validation_error(self, test_client: httpx.AsyncClient):
376376 },
377377 )
378378 error_response = response .json ()
379- assert error_response ["error" ] == "invalid_request "
380- assert "error_description" in error_response # Contains validation error messages
379+ assert error_response ["error" ] == "unauthorized_client "
380+ assert "error_description" in error_response # Contains error message
381381
382382 @pytest .mark .anyio
383383 async def test_token_invalid_auth_code (
@@ -942,6 +942,147 @@ async def test_client_registration_invalid_grant_type(self, test_client: httpx.A
942942 assert error_data ["error" ] == "invalid_client_metadata"
943943 assert error_data ["error_description" ] == "grant_types must be authorization_code and refresh_token"
944944
945+ @pytest .mark .anyio
946+ async def test_client_secret_basic_authentication (
947+ self , test_client : httpx .AsyncClient , mock_oauth_provider : MockOAuthProvider , pkce_challenge : dict [str , str ]
948+ ):
949+ """Test that client_secret_basic authentication works correctly."""
950+ client_metadata = {
951+ "redirect_uris" : ["https://client.example.com/callback" ],
952+ "client_name" : "Basic Auth Client" ,
953+ "token_endpoint_auth_method" : "client_secret_basic" ,
954+ "grant_types" : ["authorization_code" , "refresh_token" ],
955+ }
956+
957+ response = await test_client .post ("/register" , json = client_metadata )
958+ assert response .status_code == 201
959+ client_info = response .json ()
960+ assert client_info ["token_endpoint_auth_method" ] == "client_secret_basic"
961+
962+ auth_code = f"code_{ int (time .time ())} "
963+ mock_oauth_provider .auth_codes [auth_code ] = AuthorizationCode (
964+ code = auth_code ,
965+ client_id = client_info ["client_id" ],
966+ code_challenge = pkce_challenge ["code_challenge" ],
967+ redirect_uri = AnyUrl ("https://client.example.com/callback" ),
968+ redirect_uri_provided_explicitly = True ,
969+ scopes = ["read" , "write" ],
970+ expires_at = time .time () + 600 ,
971+ )
972+
973+ credentials = f"{ client_info ['client_id' ]} :{ client_info ['client_secret' ]} "
974+ encoded_credentials = base64 .b64encode (credentials .encode ()).decode ()
975+
976+ response = await test_client .post (
977+ "/token" ,
978+ headers = {"Authorization" : f"Basic { encoded_credentials } " },
979+ data = {
980+ "grant_type" : "authorization_code" ,
981+ "client_id" : client_info ["client_id" ],
982+ "code" : auth_code ,
983+ "code_verifier" : pkce_challenge ["code_verifier" ],
984+ "redirect_uri" : "https://client.example.com/callback" ,
985+ },
986+ )
987+ assert response .status_code == 200
988+ token_response = response .json ()
989+ assert "access_token" in token_response
990+
991+ @pytest .mark .anyio
992+ async def test_wrong_auth_method_without_valid_credentials_fails (
993+ self , test_client : httpx .AsyncClient , mock_oauth_provider : MockOAuthProvider , pkce_challenge : dict [str , str ]
994+ ):
995+ """Test that using the wrong authentication method fails when credentials are missing."""
996+ client_metadata = {
997+ "redirect_uris" : ["https://client.example.com/callback" ],
998+ "client_name" : "Post Auth Client" ,
999+ "token_endpoint_auth_method" : "client_secret_post" ,
1000+ "grant_types" : ["authorization_code" , "refresh_token" ],
1001+ }
1002+
1003+ response = await test_client .post ("/register" , json = client_metadata )
1004+ assert response .status_code == 201
1005+ client_info = response .json ()
1006+ assert client_info ["token_endpoint_auth_method" ] == "client_secret_post"
1007+
1008+ auth_code = f"code_{ int (time .time ())} "
1009+ mock_oauth_provider .auth_codes [auth_code ] = AuthorizationCode (
1010+ code = auth_code ,
1011+ client_id = client_info ["client_id" ],
1012+ code_challenge = pkce_challenge ["code_challenge" ],
1013+ redirect_uri = AnyUrl ("https://client.example.com/callback" ),
1014+ redirect_uri_provided_explicitly = True ,
1015+ scopes = ["read" , "write" ],
1016+ expires_at = time .time () + 600 ,
1017+ )
1018+
1019+ # Try to use Basic auth when client_secret_post is registered (without secret in body)
1020+ # This should fail because the secret is missing from the expected location
1021+
1022+ credentials = f"{ client_info ['client_id' ]} :{ client_info ['client_secret' ]} "
1023+ encoded_credentials = base64 .b64encode (credentials .encode ()).decode ()
1024+
1025+ response = await test_client .post (
1026+ "/token" ,
1027+ headers = {"Authorization" : f"Basic { encoded_credentials } " },
1028+ data = {
1029+ "grant_type" : "authorization_code" ,
1030+ "client_id" : client_info ["client_id" ],
1031+ # client_secret NOT in body where it should be
1032+ "code" : auth_code ,
1033+ "code_verifier" : pkce_challenge ["code_verifier" ],
1034+ "redirect_uri" : "https://client.example.com/callback" ,
1035+ },
1036+ )
1037+ assert response .status_code == 401
1038+ error_response = response .json ()
1039+ assert error_response ["error" ] == "unauthorized_client"
1040+ assert "Client secret is required" in error_response ["error_description" ]
1041+
1042+ @pytest .mark .anyio
1043+ async def test_basic_auth_without_header_fails (
1044+ self , test_client : httpx .AsyncClient , mock_oauth_provider : MockOAuthProvider , pkce_challenge : dict [str , str ]
1045+ ):
1046+ """Test that omitting Basic auth when client_secret_basic is registered fails."""
1047+ client_metadata = {
1048+ "redirect_uris" : ["https://client.example.com/callback" ],
1049+ "client_name" : "Basic Auth Client" ,
1050+ "token_endpoint_auth_method" : "client_secret_basic" ,
1051+ "grant_types" : ["authorization_code" , "refresh_token" ],
1052+ }
1053+
1054+ response = await test_client .post ("/register" , json = client_metadata )
1055+ assert response .status_code == 201
1056+ client_info = response .json ()
1057+ assert client_info ["token_endpoint_auth_method" ] == "client_secret_basic"
1058+
1059+ auth_code = f"code_{ int (time .time ())} "
1060+ mock_oauth_provider .auth_codes [auth_code ] = AuthorizationCode (
1061+ code = auth_code ,
1062+ client_id = client_info ["client_id" ],
1063+ code_challenge = pkce_challenge ["code_challenge" ],
1064+ redirect_uri = AnyUrl ("https://client.example.com/callback" ),
1065+ redirect_uri_provided_explicitly = True ,
1066+ scopes = ["read" , "write" ],
1067+ expires_at = time .time () + 600 ,
1068+ )
1069+
1070+ response = await test_client .post (
1071+ "/token" ,
1072+ data = {
1073+ "grant_type" : "authorization_code" ,
1074+ "client_id" : client_info ["client_id" ],
1075+ "client_secret" : client_info ["client_secret" ], # Secret in body (ignored)
1076+ "code" : auth_code ,
1077+ "code_verifier" : pkce_challenge ["code_verifier" ],
1078+ "redirect_uri" : "https://client.example.com/callback" ,
1079+ },
1080+ )
1081+ assert response .status_code == 401
1082+ error_response = response .json ()
1083+ assert error_response ["error" ] == "unauthorized_client"
1084+ assert "Missing or invalid Basic authentication" in error_response ["error_description" ]
1085+
9451086
9461087class TestAuthorizeEndpointErrors :
9471088 """Test error handling in the OAuth authorization endpoint."""
0 commit comments