Skip to content

Commit 38cbae1

Browse files
test: add case for token request with basic auth header
This test case only contains client_id and client_secret at Basic Authentication header, assuming that there is no client_id and client_secret at form data.
1 parent 6b69f63 commit 38cbae1

1 file changed

Lines changed: 265 additions & 0 deletions

File tree

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
"""
2+
Tests for the TokenHandler.
3+
"""
4+
5+
import base64
6+
import hashlib
7+
import time
8+
from typing import Any, cast
9+
10+
import pytest
11+
from pydantic import AnyUrl
12+
from starlette.requests import Request
13+
from starlette.types import Message, Scope
14+
15+
from mcp.server.auth.handlers.token import TokenHandler
16+
from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator
17+
from mcp.server.auth.provider import (
18+
AccessToken,
19+
AuthorizationCode,
20+
OAuthAuthorizationServerProvider,
21+
RefreshToken,
22+
)
23+
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
24+
25+
26+
class MockOAuthProvider:
27+
"""Mock OAuth provider for testing."""
28+
29+
def __init__(self):
30+
self.clients: dict[str, OAuthClientInformationFull] = {}
31+
self.authorization_codes: dict[str, AuthorizationCode] = {}
32+
self.refresh_tokens: dict[str, RefreshToken] = {}
33+
self.access_tokens: dict[str, AccessToken] = {}
34+
35+
def add_client(self, client: OAuthClientInformationFull) -> None:
36+
"""Add a client to the provider."""
37+
if client.client_id:
38+
self.clients[client.client_id] = client
39+
40+
def add_authorization_code(self, code: str, auth_code: AuthorizationCode) -> None:
41+
"""Add an authorization code."""
42+
self.authorization_codes[code] = auth_code
43+
44+
def add_refresh_token(self, token: str, refresh_token: RefreshToken) -> None:
45+
"""Add a refresh token."""
46+
self.refresh_tokens[token] = refresh_token
47+
48+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
49+
"""Get client by ID."""
50+
return self.clients.get(client_id)
51+
52+
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
53+
"""Register a client (not used in these tests)."""
54+
pass # pragma: no cover
55+
56+
async def authorize(self, client: OAuthClientInformationFull, params: Any) -> str:
57+
"""Authorize a client (not used in these tests)."""
58+
return "" # pragma: no cover
59+
60+
async def load_authorization_code(
61+
self, client: OAuthClientInformationFull, authorization_code: str
62+
) -> AuthorizationCode | None:
63+
"""Load authorization code."""
64+
return self.authorization_codes.get(authorization_code)
65+
66+
async def exchange_authorization_code(
67+
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
68+
) -> OAuthToken:
69+
"""Exchange authorization code for tokens."""
70+
return OAuthToken(
71+
access_token="mock_access_token",
72+
token_type="Bearer",
73+
expires_in=3600,
74+
refresh_token="mock_refresh_token",
75+
)
76+
77+
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
78+
"""Load refresh token."""
79+
return self.refresh_tokens.get(refresh_token)
80+
81+
async def exchange_refresh_token(
82+
self,
83+
client: OAuthClientInformationFull,
84+
refresh_token: RefreshToken,
85+
scopes: list[str],
86+
) -> OAuthToken:
87+
"""Exchange refresh token for new tokens."""
88+
return OAuthToken(
89+
access_token="mock_new_access_token",
90+
token_type="Bearer",
91+
expires_in=3600,
92+
refresh_token="mock_new_refresh_token",
93+
)
94+
95+
async def load_access_token(self, token: str) -> AccessToken | None:
96+
"""Load an access token."""
97+
return self.access_tokens.get(token) # pragma: no cover
98+
99+
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
100+
"""Revoke a token (not used in these tests)."""
101+
pass # pragma: no cover
102+
103+
104+
class MockClientAuthenticator:
105+
"""Mock client authenticator for testing."""
106+
107+
def __init__(self):
108+
self.should_fail = False
109+
self.client_to_return: OAuthClientInformationFull | None = None
110+
111+
async def authenticate_request(self, request: Request) -> OAuthClientInformationFull:
112+
"""Authenticate a client request."""
113+
if self.should_fail:
114+
raise AuthenticationError("Authentication failed")
115+
if self.client_to_return is None:
116+
raise AuthenticationError("No client configured")
117+
return self.client_to_return
118+
119+
120+
def create_mock_request(form_data: dict[str, str], headers: dict[str, str] | None = None) -> Request:
121+
"""Create a mock Starlette Request with form data and headers."""
122+
raw_headers: list[tuple[bytes, bytes]] = []
123+
if headers:
124+
for key, value in headers.items():
125+
raw_headers.append((key.lower().encode(), value.encode()))
126+
127+
raw_headers.append((b"content-type", b"application/x-www-form-urlencoded"))
128+
129+
scope: Scope = {
130+
"type": "http",
131+
"method": "POST",
132+
"headers": raw_headers,
133+
}
134+
135+
# Create a simple receive callable that returns form data
136+
messages: list[Message] = []
137+
138+
# Encode form data
139+
encoded_body = "&".join(f"{k}={v}" for k, v in form_data.items()).encode()
140+
messages.append(
141+
{
142+
"type": "http.request",
143+
"body": encoded_body,
144+
}
145+
)
146+
147+
async def receive() -> Message:
148+
if messages:
149+
return messages.pop(0)
150+
return {"type": "http.disconnect"}
151+
152+
request = Request(scope, receive)
153+
return request
154+
155+
156+
def generate_code_verifier() -> str:
157+
"""Generate a PKCE code verifier."""
158+
return "test_code_verifier_with_sufficient_length_for_pkce_validation"
159+
160+
161+
def generate_code_challenge(verifier: str) -> str:
162+
"""Generate a PKCE code challenge from a verifier."""
163+
sha256 = hashlib.sha256(verifier.encode()).digest()
164+
return base64.urlsafe_b64encode(sha256).decode().rstrip("=")
165+
166+
167+
@pytest.fixture
168+
def mock_oauth_provider() -> OAuthAuthorizationServerProvider[Any, Any, Any]:
169+
"""Create a mock OAuth provider."""
170+
return cast(OAuthAuthorizationServerProvider[Any, Any, Any], MockOAuthProvider())
171+
172+
173+
@pytest.fixture
174+
def mock_client_authenticator() -> ClientAuthenticator:
175+
"""Create a mock client authenticator."""
176+
return cast(ClientAuthenticator, MockClientAuthenticator())
177+
178+
179+
@pytest.fixture
180+
def test_client() -> OAuthClientInformationFull:
181+
"""Create a test client."""
182+
return OAuthClientInformationFull(
183+
client_id="test_client",
184+
client_secret="test_secret",
185+
redirect_uris=[AnyUrl("https://example.com/callback")],
186+
token_endpoint_auth_method="client_secret_basic",
187+
grant_types=["authorization_code", "refresh_token"],
188+
)
189+
190+
191+
@pytest.mark.anyio
192+
class TestTokenHandlerAuthBasic:
193+
"""Tests for TokenHandler with Auth Basic header."""
194+
195+
async def test_auth_basic_without_form_client_credentials(
196+
self,
197+
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
198+
test_client: OAuthClientInformationFull,
199+
):
200+
"""Test token request with Auth Basic header but no client_id/client_secret in form data.
201+
202+
This test validates the scenario where:
203+
- The client uses HTTP Basic authentication (Authorization: Basic header)
204+
- The form data does NOT include client_id or client_secret fields
205+
- The handler should response correctly
206+
207+
Note: This test may fail if the current implementation does not properly
208+
handle the case where client_id is missing from form_data.
209+
"""
210+
# Setup provider
211+
provider = cast(MockOAuthProvider, mock_oauth_provider)
212+
provider.add_client(test_client)
213+
# Create REAL authenticator (not mock) to test actual behavior
214+
authenticator = ClientAuthenticator(provider=provider)
215+
216+
# Create handler
217+
handler = TokenHandler(provider=provider, client_authenticator=authenticator)
218+
219+
# Generate PKCE values
220+
code_verifier = generate_code_verifier()
221+
code_challenge = generate_code_challenge(code_verifier)
222+
223+
# Add authorization code to provider
224+
auth_code = AuthorizationCode(
225+
code="test_auth_code",
226+
scopes=["read", "write"],
227+
expires_at=time.time() + 600, # 10 minutes from now
228+
client_id="test_client",
229+
code_challenge=code_challenge,
230+
redirect_uri=AnyUrl("https://example.com/callback"),
231+
redirect_uri_provided_explicitly=True,
232+
)
233+
provider.add_authorization_code("test_auth_code", auth_code)
234+
235+
# Create Basic auth header
236+
credentials = f"{test_client.client_id}:{test_client.client_secret}"
237+
encoded_credentials = base64.b64encode(credentials.encode()).decode()
238+
auth_header = f"Basic {encoded_credentials}"
239+
240+
# Create form data WITHOUT client_id and client_secret
241+
form_data = {
242+
"grant_type": "authorization_code",
243+
"code": "test_auth_code",
244+
"redirect_uri": "https://example.com/callback",
245+
"code_verifier": code_verifier,
246+
}
247+
248+
# Create request with Auth Basic header
249+
request = create_mock_request(form_data, headers={"Authorization": auth_header})
250+
251+
# Execute the handler
252+
response = await handler.handle(request)
253+
254+
255+
# Validate the response
256+
# Note: This test may fail if client_id is not extracted from the Basic auth header
257+
# or form_data, since the handler expects client_id in the form_data
258+
assert response is not None
259+
260+
if response.status_code != 200:
261+
# If not successful, print the response body for debugging
262+
body_bytes = bytes(response.body)
263+
body = body_bytes.decode()
264+
pytest.fail(f"Handler response error: {body}")
265+

0 commit comments

Comments
 (0)