Skip to content

Commit c51936f

Browse files
jonsheapcarleton
andauthored
Add client_secret_basic authentication support (#1334)
Co-authored-by: Paul Carleton <[email protected]>
1 parent 397089a commit c51936f

File tree

10 files changed

+695
-59
lines changed

10 files changed

+695
-59
lines changed

examples/clients/simple-auth-client/mcp_simple_auth_client/main.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,6 @@ async def callback_handler() -> tuple[str, str | None]:
177177
"redirect_uris": ["http://localhost:3030/callback"],
178178
"grant_types": ["authorization_code", "refresh_token"],
179179
"response_types": ["code"],
180-
"token_endpoint_auth_method": "client_secret_post",
181180
}
182181

183182
async def _default_redirect_handler(authorization_url: str) -> None:

src/mcp/client/auth/oauth2.py

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@
1313
from collections.abc import AsyncGenerator, Awaitable, Callable
1414
from dataclasses import dataclass, field
1515
from typing import Any, Protocol
16-
from urllib.parse import urlencode, urljoin, urlparse
16+
from urllib.parse import quote, urlencode, urljoin, urlparse
1717

1818
import anyio
1919
import httpx
2020
from pydantic import BaseModel, Field, ValidationError
2121

22-
from mcp.client.auth import OAuthFlowError, OAuthTokenError
22+
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
2323
from mcp.client.auth.utils import (
2424
build_oauth_authorization_server_metadata_discovery_urls,
2525
build_protected_resource_metadata_discovery_urls,
@@ -173,6 +173,42 @@ def should_include_resource_param(self, protocol_version: str | None = None) ->
173173
# Version format is YYYY-MM-DD, so string comparison works
174174
return protocol_version >= "2025-06-18"
175175

176+
def prepare_token_auth(
177+
self, data: dict[str, str], headers: dict[str, str] | None = None
178+
) -> tuple[dict[str, str], dict[str, str]]:
179+
"""Prepare authentication for token requests.
180+
181+
Args:
182+
data: The form data to send
183+
headers: Optional headers dict to update
184+
185+
Returns:
186+
Tuple of (updated_data, updated_headers)
187+
"""
188+
if headers is None:
189+
headers = {} # pragma: no cover
190+
191+
if not self.client_info:
192+
return data, headers # pragma: no cover
193+
194+
auth_method = self.client_info.token_endpoint_auth_method
195+
196+
if auth_method == "client_secret_basic" and self.client_info.client_id and self.client_info.client_secret:
197+
# URL-encode client ID and secret per RFC 6749 Section 2.3.1
198+
encoded_id = quote(self.client_info.client_id, safe="")
199+
encoded_secret = quote(self.client_info.client_secret, safe="")
200+
credentials = f"{encoded_id}:{encoded_secret}"
201+
encoded_credentials = base64.b64encode(credentials.encode()).decode()
202+
headers["Authorization"] = f"Basic {encoded_credentials}"
203+
# Don't include client_secret in body for basic auth
204+
data = {k: v for k, v in data.items() if k != "client_secret"}
205+
elif auth_method == "client_secret_post" and self.client_info.client_secret:
206+
# Include client_secret in request body
207+
data["client_secret"] = self.client_info.client_secret
208+
# For auth_method == "none", don't add any client_secret
209+
210+
return data, headers
211+
176212

177213
class OAuthClientProvider(httpx.Auth):
178214
"""
@@ -247,6 +283,27 @@ async def _register_client(self) -> httpx.Request | None:
247283

248284
registration_data = self.context.client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True)
249285

286+
# If token_endpoint_auth_method is None, auto-select based on server support
287+
if self.context.client_metadata.token_endpoint_auth_method is None:
288+
preference_order = ["client_secret_basic", "client_secret_post", "none"]
289+
290+
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint_auth_methods_supported:
291+
supported = self.context.oauth_metadata.token_endpoint_auth_methods_supported
292+
for method in preference_order:
293+
if method in supported:
294+
registration_data["token_endpoint_auth_method"] = method
295+
break
296+
else:
297+
# No compatible methods between client and server
298+
raise OAuthRegistrationError(
299+
f"No compatible authentication methods. "
300+
f"Server supports: {supported}, "
301+
f"Client supports: {preference_order}"
302+
)
303+
else:
304+
# No server metadata available, use our default preference
305+
registration_data["token_endpoint_auth_method"] = preference_order[0]
306+
250307
return httpx.Request(
251308
"POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"}
252309
)
@@ -343,12 +400,11 @@ async def _exchange_token_authorization_code(
343400
if self.context.should_include_resource_param(self.context.protocol_version):
344401
token_data["resource"] = self.context.get_resource_url() # RFC 8707
345402

346-
if self.context.client_info.client_secret:
347-
token_data["client_secret"] = self.context.client_info.client_secret
403+
# Prepare authentication based on preferred method
404+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
405+
token_data, headers = self.context.prepare_token_auth(token_data, headers)
348406

349-
return httpx.Request(
350-
"POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
351-
)
407+
return httpx.Request("POST", token_url, data=token_data, headers=headers)
352408

353409
async def _handle_token_response(self, response: httpx.Response) -> None:
354410
"""Handle token exchange response."""
@@ -370,7 +426,7 @@ async def _refresh_token(self) -> httpx.Request:
370426
if not self.context.current_tokens or not self.context.current_tokens.refresh_token:
371427
raise OAuthTokenError("No refresh token available") # pragma: no cover
372428

373-
if not self.context.client_info:
429+
if not self.context.client_info or not self.context.client_info.client_id:
374430
raise OAuthTokenError("No client info available") # pragma: no cover
375431

376432
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
@@ -379,7 +435,7 @@ async def _refresh_token(self) -> httpx.Request:
379435
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
380436
token_url = urljoin(auth_base_url, "/token")
381437

382-
refresh_data = {
438+
refresh_data: dict[str, str] = {
383439
"grant_type": "refresh_token",
384440
"refresh_token": self.context.current_tokens.refresh_token,
385441
"client_id": self.context.client_info.client_id,
@@ -389,12 +445,11 @@ async def _refresh_token(self) -> httpx.Request:
389445
if self.context.should_include_resource_param(self.context.protocol_version):
390446
refresh_data["resource"] = self.context.get_resource_url() # RFC 8707
391447

392-
if self.context.client_info.client_secret: # pragma: no branch
393-
refresh_data["client_secret"] = self.context.client_info.client_secret
448+
# Prepare authentication based on preferred method
449+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
450+
refresh_data, headers = self.context.prepare_token_auth(refresh_data, headers)
394451

395-
return httpx.Request(
396-
"POST", token_url, data=refresh_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
397-
)
452+
return httpx.Request("POST", token_url, data=refresh_data, headers=headers)
398453

399454
async def _handle_refresh_response(self, response: httpx.Response) -> bool: # pragma: no cover
400455
"""Handle token refresh response. Returns True if successful."""

src/mcp/server/auth/handlers/register.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ async def handle(self, request: Request) -> Response:
4949
)
5050

5151
client_id = str(uuid4())
52+
53+
# If auth method is None, default to client_secret_post
54+
if client_metadata.token_endpoint_auth_method is None:
55+
client_metadata.token_endpoint_auth_method = "client_secret_post"
56+
5257
client_secret = None
5358
if client_metadata.token_endpoint_auth_method != "none": # pragma: no branch
5459
# cryptographically secure random 32-byte hex string

src/mcp/server/auth/handlers/revoke.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,28 +40,25 @@ async def handle(self, request: Request) -> Response:
4040
Handler for the OAuth 2.0 Token Revocation endpoint.
4141
"""
4242
try:
43-
form_data = await request.form()
44-
revocation_request = RevocationRequest.model_validate(dict(form_data))
45-
except ValidationError as e:
43+
client = await self.client_authenticator.authenticate_request(request)
44+
except AuthenticationError as e: # pragma: no cover
4645
return PydanticJSONResponse(
47-
status_code=400,
46+
status_code=401,
4847
content=RevocationErrorResponse(
49-
error="invalid_request",
50-
error_description=stringify_pydantic_error(e),
48+
error="unauthorized_client",
49+
error_description=e.message,
5150
),
5251
)
5352

54-
# Authenticate client
5553
try:
56-
client = await self.client_authenticator.authenticate(
57-
revocation_request.client_id, revocation_request.client_secret
58-
)
59-
except AuthenticationError as e: # pragma: no cover
54+
form_data = await request.form()
55+
revocation_request = RevocationRequest.model_validate(dict(form_data))
56+
except ValidationError as e:
6057
return PydanticJSONResponse(
61-
status_code=401,
58+
status_code=400,
6259
content=RevocationErrorResponse(
63-
error="unauthorized_client",
64-
error_description=e.message,
60+
error="invalid_request",
61+
error_description=stringify_pydantic_error(e),
6562
),
6663
)
6764

src/mcp/server/auth/handlers/token.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -91,30 +91,33 @@ def response(self, obj: TokenSuccessResponse | TokenErrorResponse):
9191
)
9292

9393
async def handle(self, request: Request):
94+
try:
95+
client_info = await self.client_authenticator.authenticate_request(request)
96+
except AuthenticationError as e:
97+
# Authentication failures should return 401
98+
return PydanticJSONResponse(
99+
content=TokenErrorResponse(
100+
error="unauthorized_client",
101+
error_description=e.message,
102+
),
103+
status_code=401,
104+
headers={
105+
"Cache-Control": "no-store",
106+
"Pragma": "no-cache",
107+
},
108+
)
109+
94110
try:
95111
form_data = await request.form()
96112
token_request = TokenRequest.model_validate(dict(form_data)).root
97-
except ValidationError as validation_error:
113+
except ValidationError as validation_error: # pragma: no cover
98114
return self.response(
99115
TokenErrorResponse(
100116
error="invalid_request",
101117
error_description=stringify_pydantic_error(validation_error),
102118
)
103119
)
104120

105-
try:
106-
client_info = await self.client_authenticator.authenticate(
107-
client_id=token_request.client_id,
108-
client_secret=token_request.client_secret,
109-
)
110-
except AuthenticationError as e: # pragma: no cover
111-
return self.response(
112-
TokenErrorResponse(
113-
error="unauthorized_client",
114-
error_description=e.message,
115-
)
116-
)
117-
118121
if token_request.grant_type not in client_info.grant_types: # pragma: no cover
119122
return self.response(
120123
TokenErrorResponse(

src/mcp/server/auth/middleware/client_auth.py

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
import base64
2+
import binascii
3+
import hmac
14
import time
25
from typing import Any
6+
from urllib.parse import unquote
7+
8+
from starlette.requests import Request
39

410
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
511
from mcp.shared.auth import OAuthClientInformationFull
@@ -30,19 +36,77 @@ def __init__(self, provider: OAuthAuthorizationServerProvider[Any, Any, Any]):
3036
"""
3137
self.provider = provider
3238

33-
async def authenticate(self, client_id: str, client_secret: str | None) -> OAuthClientInformationFull:
34-
# Look up client information
35-
client = await self.provider.get_client(client_id)
39+
async def authenticate_request(self, request: Request) -> OAuthClientInformationFull:
40+
"""
41+
Authenticate a client from an HTTP request.
42+
43+
Extracts client credentials from the appropriate location based on the
44+
client's registered authentication method and validates them.
45+
46+
Args:
47+
request: The HTTP request containing client credentials
48+
49+
Returns:
50+
The authenticated client information
51+
52+
Raises:
53+
AuthenticationError: If authentication fails
54+
"""
55+
form_data = await request.form()
56+
client_id = form_data.get("client_id")
57+
if not client_id:
58+
raise AuthenticationError("Missing client_id")
59+
60+
client = await self.provider.get_client(str(client_id))
3661
if not client:
3762
raise AuthenticationError("Invalid client_id") # pragma: no cover
3863

64+
request_client_secret: str | None = None
65+
auth_header = request.headers.get("Authorization", "")
66+
67+
if client.token_endpoint_auth_method == "client_secret_basic":
68+
if not auth_header.startswith("Basic "):
69+
raise AuthenticationError("Missing or invalid Basic authentication in Authorization header")
70+
71+
try:
72+
encoded_credentials = auth_header[6:] # Remove "Basic " prefix
73+
decoded = base64.b64decode(encoded_credentials).decode("utf-8")
74+
if ":" not in decoded:
75+
raise ValueError("Invalid Basic auth format")
76+
basic_client_id, request_client_secret = decoded.split(":", 1)
77+
78+
# URL-decode both parts per RFC 6749 Section 2.3.1
79+
basic_client_id = unquote(basic_client_id)
80+
request_client_secret = unquote(request_client_secret)
81+
82+
if basic_client_id != client_id:
83+
raise AuthenticationError("Client ID mismatch in Basic auth")
84+
except (ValueError, UnicodeDecodeError, binascii.Error):
85+
raise AuthenticationError("Invalid Basic authentication header")
86+
87+
elif client.token_endpoint_auth_method == "client_secret_post":
88+
raw_form_data = form_data.get("client_secret")
89+
# form_data.get() can return a UploadFile or None, so we need to check if it's a string
90+
if isinstance(raw_form_data, str):
91+
request_client_secret = str(raw_form_data)
92+
93+
elif client.token_endpoint_auth_method == "none":
94+
request_client_secret = None
95+
else:
96+
raise AuthenticationError( # pragma: no cover
97+
f"Unsupported auth method: {client.token_endpoint_auth_method}"
98+
)
99+
39100
# If client from the store expects a secret, validate that the request provides
40101
# that secret
41102
if client.client_secret: # pragma: no branch
42-
if not client_secret:
103+
if not request_client_secret:
43104
raise AuthenticationError("Client secret is required") # pragma: no cover
44105

45-
if client.client_secret != client_secret:
106+
# hmac.compare_digest requires that both arguments are either bytes or a `str` containing
107+
# only ASCII characters. Since we do not control `request_client_secret`, we encode both
108+
# arguments to bytes.
109+
if not hmac.compare_digest(client.client_secret.encode(), request_client_secret.encode()):
46110
raise AuthenticationError("Invalid client_secret") # pragma: no cover
47111

48112
if client.client_secret_expires_at and client.client_secret_expires_at < int(time.time()):

src/mcp/server/auth/routes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def build_metadata(
165165
response_types_supported=["code"],
166166
response_modes_supported=None,
167167
grant_types_supported=["authorization_code", "refresh_token"],
168-
token_endpoint_auth_methods_supported=["client_secret_post"],
168+
token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"],
169169
token_endpoint_auth_signing_alg_values_supported=None,
170170
service_documentation=service_documentation_url,
171171
ui_locales_supported=None,
@@ -182,7 +182,7 @@ def build_metadata(
182182
# Add revocation endpoint if supported
183183
if revocation_options.enabled: # pragma: no branch
184184
metadata.revocation_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REVOCATION_PATH)
185-
metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post"]
185+
metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post", "client_secret_basic"]
186186

187187
return metadata
188188

src/mcp/shared/auth.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ class OAuthClientMetadata(BaseModel):
4343

4444
redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
4545
# supported auth methods for the token endpoint
46-
token_endpoint_auth_method: Literal["none", "client_secret_post", "private_key_jwt"] = "client_secret_post"
46+
token_endpoint_auth_method: (
47+
Literal["none", "client_secret_post", "client_secret_basic", "private_key_jwt"] | None
48+
) = None
4749
# supported grant_types of this implementation
4850
grant_types: list[
4951
Literal["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | str

0 commit comments

Comments
 (0)