Skip to content

Commit ccc69fb

Browse files
author
SpiliosDmk
committed
fix(client): don't send client_id in SEP-990 token body under client_secret_basic
Same issue as #3138, in the independent identity_assertion.py (SEP-990 jwt-bearer) implementation: client_id was always placed in the request body and never stripped for client_secret_basic, even though it's already presented via the Authorization header. RFC 6749 §2.3 requires it not appear in both places. Fixes #XXXX
1 parent 837ef90 commit ccc69fb

2 files changed

Lines changed: 25 additions & 1 deletion

File tree

src/mcp/client/auth/extensions/identity_assertion.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,6 @@ def _build_token_request(self, scope: str | None, assertion: str) -> httpx2.Requ
143143
data: dict[str, str] = {
144144
"grant_type": JWT_BEARER_GRANT_TYPE,
145145
"assertion": assertion,
146-
"client_id": self._client.client_id,
147146
"resource": self._resource,
148147
}
149148
if scope:
@@ -155,7 +154,10 @@ def _build_token_request(self, scope: str | None, assertion: str) -> httpx2.Requ
155154
encoded_secret = quote(self._client.client_secret, safe="")
156155
credentials = base64.b64encode(f"{encoded_id}:{encoded_secret}".encode()).decode()
157156
headers["Authorization"] = f"Basic {credentials}"
157+
# RFC 6749 section 2.3: client_id must not also appear in the body when it's
158+
# already presented via the Authorization header.
158159
else:
160+
data["client_id"] = self._client.client_id
159161
data["client_secret"] = self._client.client_secret
160162
return httpx2.Request("POST", self._token_endpoint, data=data, headers=headers)
161163

tests/client/auth/extensions/test_identity_assertion.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,8 @@ async def test_token_endpoint_error_surfaces_as_oauth_token_error() -> None:
293293

294294
@pytest.mark.anyio
295295
async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None:
296+
"""RFC 6749 §2.3: with Basic auth, neither client_secret nor client_id may also appear
297+
in the body -- both are already presented via the Authorization header."""
296298
requests: list[httpx2.Request] = []
297299
auth = make_provider(token_endpoint_auth_method="client_secret_basic")
298300

@@ -303,10 +305,30 @@ async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None:
303305

304306
[token_req] = [r for r in requests if r.url.path == "/token"]
305307
assert "client_secret" not in form(token_req)
308+
assert "client_id" not in form(token_req)
306309
decoded = base64.b64decode(token_req.headers["Authorization"].removeprefix("Basic ")).decode()
307310
assert decoded == "test-client-id:test-client-secret"
308311

309312

313+
@pytest.mark.anyio
314+
async def test_client_secret_post_sends_client_id_and_secret_in_body() -> None:
315+
"""Sanity check / regression guard for the client_secret_basic fix above: the post method
316+
is unaffected and still sends both client_id and client_secret in the body, not a header."""
317+
requests: list[httpx2.Request] = []
318+
auth = make_provider(token_endpoint_auth_method="client_secret_post")
319+
320+
async with httpx2.AsyncClient(
321+
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
322+
) as http:
323+
await http.post(f"{RS}/mcp")
324+
325+
[token_req] = [r for r in requests if r.url.path == "/token"]
326+
assert "Authorization" not in token_req.headers
327+
body = form(token_req)
328+
assert body["client_id"] == "test-client-id"
329+
assert body["client_secret"] == "test-client-secret"
330+
331+
310332
@pytest.mark.anyio
311333
async def test_stored_token_is_reused_without_reauthorizing() -> None:
312334
"""A valid stored token is sent on the first request; on success no ASM or /token is fetched."""

0 commit comments

Comments
 (0)