Skip to content

Commit 7c02248

Browse files
Giulio LeoneCopilot
andauthored
fix(oauth): include client_id in token request body for client_secret_post (#2185)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 528abfa commit 7c02248

File tree

2 files changed

+69
-2
lines changed

2 files changed

+69
-2
lines changed

src/mcp/client/auth/oauth2.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,9 @@ def prepare_token_auth(
205205
headers["Authorization"] = f"Basic {encoded_credentials}"
206206
# Don't include client_secret in body for basic auth
207207
data = {k: v for k, v in data.items() if k != "client_secret"}
208-
elif auth_method == "client_secret_post" and self.client_info.client_secret:
209-
# Include client_secret in request body
208+
elif auth_method == "client_secret_post" and self.client_info.client_id and self.client_info.client_secret:
209+
# Include client_id and client_secret in request body (RFC 6749 §2.3.1)
210+
data["client_id"] = self.client_info.client_id
210211
data["client_secret"] = self.client_info.client_secret
211212
# For auth_method == "none", don't add any client_secret
212213

tests/client/auth/extensions/test_client_credentials.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,72 @@ async def test_exchange_token_client_credentials(self, mock_storage: MockTokenSt
252252
assert "scope=read write" in content
253253
assert "resource=https://api.example.com/v1/mcp" in content
254254

255+
@pytest.mark.anyio
256+
async def test_exchange_token_client_secret_post_includes_client_id(self, mock_storage: MockTokenStorage):
257+
"""Test that client_secret_post includes both client_id and client_secret in body (RFC 6749 §2.3.1)."""
258+
provider = ClientCredentialsOAuthProvider(
259+
server_url="https://api.example.com/v1/mcp",
260+
storage=mock_storage,
261+
client_id="test-client-id",
262+
client_secret="test-client-secret",
263+
token_endpoint_auth_method="client_secret_post",
264+
scopes="read write",
265+
)
266+
await provider._initialize()
267+
provider.context.oauth_metadata = OAuthMetadata(
268+
issuer=AnyHttpUrl("https://api.example.com"),
269+
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
270+
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
271+
)
272+
provider.context.protocol_version = "2025-06-18"
273+
274+
request = await provider._perform_authorization()
275+
276+
content = urllib.parse.unquote_plus(request.content.decode())
277+
assert "grant_type=client_credentials" in content
278+
assert "client_id=test-client-id" in content
279+
assert "client_secret=test-client-secret" in content
280+
# Should NOT have Basic auth header
281+
assert "Authorization" not in request.headers
282+
283+
@pytest.mark.anyio
284+
async def test_exchange_token_client_secret_post_without_client_id(self, mock_storage: MockTokenStorage):
285+
"""Test client_secret_post skips body credentials when client_id is None."""
286+
provider = ClientCredentialsOAuthProvider(
287+
server_url="https://api.example.com/v1/mcp",
288+
storage=mock_storage,
289+
client_id="placeholder",
290+
client_secret="test-client-secret",
291+
token_endpoint_auth_method="client_secret_post",
292+
scopes="read write",
293+
)
294+
await provider._initialize()
295+
provider.context.oauth_metadata = OAuthMetadata(
296+
issuer=AnyHttpUrl("https://api.example.com"),
297+
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
298+
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
299+
)
300+
provider.context.protocol_version = "2025-06-18"
301+
# Override client_info to have client_id=None (edge case)
302+
provider.context.client_info = OAuthClientInformationFull(
303+
redirect_uris=None,
304+
client_id=None,
305+
client_secret="test-client-secret",
306+
grant_types=["client_credentials"],
307+
token_endpoint_auth_method="client_secret_post",
308+
scope="read write",
309+
)
310+
311+
request = await provider._perform_authorization()
312+
313+
content = urllib.parse.unquote_plus(request.content.decode())
314+
assert "grant_type=client_credentials" in content
315+
# Neither client_id nor client_secret should be in body since client_id is None
316+
# (RFC 6749 §2.3.1 requires both for client_secret_post)
317+
assert "client_id=" not in content
318+
assert "client_secret=" not in content
319+
assert "Authorization" not in request.headers
320+
255321
@pytest.mark.anyio
256322
async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorage):
257323
"""Test token exchange without scopes."""

0 commit comments

Comments
 (0)