12
12
13
13
import httpx
14
14
import pytest
15
- from pydantic import AnyHttpUrl
15
+ from pydantic import AnyHttpUrl , AnyUrl
16
16
from starlette .applications import Starlette
17
17
18
18
from mcp .server .auth .provider import (
@@ -354,7 +354,7 @@ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
354
354
assert metadata ["revocation_endpoint" ] == "https://auth.example.com/revoke"
355
355
assert metadata ["response_types_supported" ] == ["code" ]
356
356
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" ]
358
358
assert metadata ["grant_types_supported" ] == [
359
359
"authorization_code" ,
360
360
"refresh_token" ,
@@ -373,8 +373,8 @@ async def test_token_validation_error(self, test_client: httpx.AsyncClient):
373
373
},
374
374
)
375
375
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
378
378
379
379
@pytest .mark .anyio
380
380
async def test_token_invalid_auth_code (
@@ -1010,6 +1010,147 @@ async def test_client_registration_default_response_types(
1010
1010
assert "response_types" in data
1011
1011
assert data ["response_types" ] == ["code" ]
1012
1012
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
+
1013
1154
1014
1155
class TestAuthorizeEndpointErrors :
1015
1156
"""Test error handling in the OAuth authorization endpoint."""
0 commit comments