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 (
@@ -357,7 +357,7 @@ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
357
357
assert metadata ["revocation_endpoint" ] == "https://auth.example.com/revoke"
358
358
assert metadata ["response_types_supported" ] == ["code" ]
359
359
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" ]
361
361
assert metadata ["grant_types_supported" ] == [
362
362
"authorization_code" ,
363
363
"refresh_token" ,
@@ -376,8 +376,8 @@ async def test_token_validation_error(self, test_client: httpx.AsyncClient):
376
376
},
377
377
)
378
378
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
381
381
382
382
@pytest .mark .anyio
383
383
async def test_token_invalid_auth_code (
@@ -942,6 +942,147 @@ async def test_client_registration_invalid_grant_type(self, test_client: httpx.A
942
942
assert error_data ["error" ] == "invalid_client_metadata"
943
943
assert error_data ["error_description" ] == "grant_types must be authorization_code and refresh_token"
944
944
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
+
945
1086
946
1087
class TestAuthorizeEndpointErrors :
947
1088
"""Test error handling in the OAuth authorization endpoint."""
0 commit comments