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 (
@@ -354,7 +354,7 @@ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
354354 assert metadata ["revocation_endpoint" ] == "https://auth.example.com/revoke"
355355 assert metadata ["response_types_supported" ] == ["code" ]
356356 assert metadata ["code_challenge_methods_supported" ] == ["S256" ]
357- assert metadata ["token_endpoint_auth_methods_supported" ] == ["client_secret_post" ]
357+ assert metadata ["token_endpoint_auth_methods_supported" ] == ["client_secret_post" , "client_secret_basic" ]
358358 assert metadata ["grant_types_supported" ] == [
359359 "authorization_code" ,
360360 "refresh_token" ,
@@ -373,8 +373,8 @@ async def test_token_validation_error(self, test_client: httpx.AsyncClient):
373373 },
374374 )
375375 error_response = response .json ()
376- assert error_response ["error" ] == "invalid_request "
377- assert "error_description" in error_response # Contains validation error messages
376+ assert error_response ["error" ] == "unauthorized_client "
377+ assert "error_description" in error_response # Contains error message
378378
379379 @pytest .mark .anyio
380380 async def test_token_invalid_auth_code (
@@ -1010,6 +1010,147 @@ async def test_client_registration_default_response_types(
10101010 assert "response_types" in data
10111011 assert data ["response_types" ] == ["code" ]
10121012
1013+ @pytest .mark .anyio
1014+ async def test_client_secret_basic_authentication (
1015+ self , test_client : httpx .AsyncClient , mock_oauth_provider : MockOAuthProvider , pkce_challenge : dict [str , str ]
1016+ ):
1017+ """Test that client_secret_basic authentication works correctly."""
1018+ client_metadata = {
1019+ "redirect_uris" : ["https://client.example.com/callback" ],
1020+ "client_name" : "Basic Auth Client" ,
1021+ "token_endpoint_auth_method" : "client_secret_basic" ,
1022+ "grant_types" : ["authorization_code" , "refresh_token" ],
1023+ }
1024+
1025+ response = await test_client .post ("/register" , json = client_metadata )
1026+ assert response .status_code == 201
1027+ client_info = response .json ()
1028+ assert client_info ["token_endpoint_auth_method" ] == "client_secret_basic"
1029+
1030+ auth_code = f"code_{ int (time .time ())} "
1031+ mock_oauth_provider .auth_codes [auth_code ] = AuthorizationCode (
1032+ code = auth_code ,
1033+ client_id = client_info ["client_id" ],
1034+ code_challenge = pkce_challenge ["code_challenge" ],
1035+ redirect_uri = AnyUrl ("https://client.example.com/callback" ),
1036+ redirect_uri_provided_explicitly = True ,
1037+ scopes = ["read" , "write" ],
1038+ expires_at = time .time () + 600 ,
1039+ )
1040+
1041+ credentials = f"{ client_info ['client_id' ]} :{ client_info ['client_secret' ]} "
1042+ encoded_credentials = base64 .b64encode (credentials .encode ()).decode ()
1043+
1044+ response = await test_client .post (
1045+ "/token" ,
1046+ headers = {"Authorization" : f"Basic { encoded_credentials } " },
1047+ data = {
1048+ "grant_type" : "authorization_code" ,
1049+ "client_id" : client_info ["client_id" ],
1050+ "code" : auth_code ,
1051+ "code_verifier" : pkce_challenge ["code_verifier" ],
1052+ "redirect_uri" : "https://client.example.com/callback" ,
1053+ },
1054+ )
1055+ assert response .status_code == 200
1056+ token_response = response .json ()
1057+ assert "access_token" in token_response
1058+
1059+ @pytest .mark .anyio
1060+ async def test_wrong_auth_method_without_valid_credentials_fails (
1061+ self , test_client : httpx .AsyncClient , mock_oauth_provider : MockOAuthProvider , pkce_challenge : dict [str , str ]
1062+ ):
1063+ """Test that using the wrong authentication method fails when credentials are missing."""
1064+ client_metadata = {
1065+ "redirect_uris" : ["https://client.example.com/callback" ],
1066+ "client_name" : "Post Auth Client" ,
1067+ "token_endpoint_auth_method" : "client_secret_post" ,
1068+ "grant_types" : ["authorization_code" , "refresh_token" ],
1069+ }
1070+
1071+ response = await test_client .post ("/register" , json = client_metadata )
1072+ assert response .status_code == 201
1073+ client_info = response .json ()
1074+ assert client_info ["token_endpoint_auth_method" ] == "client_secret_post"
1075+
1076+ auth_code = f"code_{ int (time .time ())} "
1077+ mock_oauth_provider .auth_codes [auth_code ] = AuthorizationCode (
1078+ code = auth_code ,
1079+ client_id = client_info ["client_id" ],
1080+ code_challenge = pkce_challenge ["code_challenge" ],
1081+ redirect_uri = AnyUrl ("https://client.example.com/callback" ),
1082+ redirect_uri_provided_explicitly = True ,
1083+ scopes = ["read" , "write" ],
1084+ expires_at = time .time () + 600 ,
1085+ )
1086+
1087+ # Try to use Basic auth when client_secret_post is registered (without secret in body)
1088+ # This should fail because the secret is missing from the expected location
1089+
1090+ credentials = f"{ client_info ['client_id' ]} :{ client_info ['client_secret' ]} "
1091+ encoded_credentials = base64 .b64encode (credentials .encode ()).decode ()
1092+
1093+ response = await test_client .post (
1094+ "/token" ,
1095+ headers = {"Authorization" : f"Basic { encoded_credentials } " },
1096+ data = {
1097+ "grant_type" : "authorization_code" ,
1098+ "client_id" : client_info ["client_id" ],
1099+ # client_secret NOT in body where it should be
1100+ "code" : auth_code ,
1101+ "code_verifier" : pkce_challenge ["code_verifier" ],
1102+ "redirect_uri" : "https://client.example.com/callback" ,
1103+ },
1104+ )
1105+ assert response .status_code == 401
1106+ error_response = response .json ()
1107+ assert error_response ["error" ] == "unauthorized_client"
1108+ assert "Client secret is required" in error_response ["error_description" ]
1109+
1110+ @pytest .mark .anyio
1111+ async def test_basic_auth_without_header_fails (
1112+ self , test_client : httpx .AsyncClient , mock_oauth_provider : MockOAuthProvider , pkce_challenge : dict [str , str ]
1113+ ):
1114+ """Test that omitting Basic auth when client_secret_basic is registered fails."""
1115+ client_metadata = {
1116+ "redirect_uris" : ["https://client.example.com/callback" ],
1117+ "client_name" : "Basic Auth Client" ,
1118+ "token_endpoint_auth_method" : "client_secret_basic" ,
1119+ "grant_types" : ["authorization_code" , "refresh_token" ],
1120+ }
1121+
1122+ response = await test_client .post ("/register" , json = client_metadata )
1123+ assert response .status_code == 201
1124+ client_info = response .json ()
1125+ assert client_info ["token_endpoint_auth_method" ] == "client_secret_basic"
1126+
1127+ auth_code = f"code_{ int (time .time ())} "
1128+ mock_oauth_provider .auth_codes [auth_code ] = AuthorizationCode (
1129+ code = auth_code ,
1130+ client_id = client_info ["client_id" ],
1131+ code_challenge = pkce_challenge ["code_challenge" ],
1132+ redirect_uri = AnyUrl ("https://client.example.com/callback" ),
1133+ redirect_uri_provided_explicitly = True ,
1134+ scopes = ["read" , "write" ],
1135+ expires_at = time .time () + 600 ,
1136+ )
1137+
1138+ response = await test_client .post (
1139+ "/token" ,
1140+ data = {
1141+ "grant_type" : "authorization_code" ,
1142+ "client_id" : client_info ["client_id" ],
1143+ "client_secret" : client_info ["client_secret" ], # Secret in body (ignored)
1144+ "code" : auth_code ,
1145+ "code_verifier" : pkce_challenge ["code_verifier" ],
1146+ "redirect_uri" : "https://client.example.com/callback" ,
1147+ },
1148+ )
1149+ assert response .status_code == 401
1150+ error_response = response .json ()
1151+ assert error_response ["error" ] == "unauthorized_client"
1152+ assert "Missing or invalid Basic authentication" in error_response ["error_description" ]
1153+
10131154
10141155class TestAuthorizeEndpointErrors :
10151156 """Test error handling in the OAuth authorization endpoint."""
0 commit comments