diff --git a/src/mcp/client/auth/extensions/identity_assertion.py b/src/mcp/client/auth/extensions/identity_assertion.py index 17738a70bb..88c48e401c 100644 --- a/src/mcp/client/auth/extensions/identity_assertion.py +++ b/src/mcp/client/auth/extensions/identity_assertion.py @@ -143,7 +143,6 @@ def _build_token_request(self, scope: str | None, assertion: str) -> httpx2.Requ data: dict[str, str] = { "grant_type": JWT_BEARER_GRANT_TYPE, "assertion": assertion, - "client_id": self._client.client_id, "resource": self._resource, } if scope: @@ -155,7 +154,10 @@ def _build_token_request(self, scope: str | None, assertion: str) -> httpx2.Requ encoded_secret = quote(self._client.client_secret, safe="") credentials = base64.b64encode(f"{encoded_id}:{encoded_secret}".encode()).decode() headers["Authorization"] = f"Basic {credentials}" + # RFC 6749 section 2.3: client_id must not also appear in the body when it's + # already presented via the Authorization header. else: + data["client_id"] = self._client.client_id data["client_secret"] = self._client.client_secret return httpx2.Request("POST", self._token_endpoint, data=data, headers=headers) diff --git a/tests/client/auth/extensions/test_identity_assertion.py b/tests/client/auth/extensions/test_identity_assertion.py index d4c965edc5..5f9cde25cb 100644 --- a/tests/client/auth/extensions/test_identity_assertion.py +++ b/tests/client/auth/extensions/test_identity_assertion.py @@ -293,6 +293,8 @@ async def test_token_endpoint_error_surfaces_as_oauth_token_error() -> None: @pytest.mark.anyio async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None: + """RFC 6749 ยง2.3: with Basic auth, neither client_secret nor client_id may also appear + in the body -- both are already presented via the Authorization header.""" requests: list[httpx2.Request] = [] auth = make_provider(token_endpoint_auth_method="client_secret_basic") @@ -303,10 +305,30 @@ async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None: [token_req] = [r for r in requests if r.url.path == "/token"] assert "client_secret" not in form(token_req) + assert "client_id" not in form(token_req) decoded = base64.b64decode(token_req.headers["Authorization"].removeprefix("Basic ")).decode() assert decoded == "test-client-id:test-client-secret" +@pytest.mark.anyio +async def test_client_secret_post_sends_client_id_and_secret_in_body() -> None: + """Sanity check / regression guard for the client_secret_basic fix above: the post method + is unaffected and still sends both client_id and client_secret in the body, not a header.""" + requests: list[httpx2.Request] = [] + auth = make_provider(token_endpoint_auth_method="client_secret_post") + + async with httpx2.AsyncClient( + transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth + ) as http: + await http.post(f"{RS}/mcp") + + [token_req] = [r for r in requests if r.url.path == "/token"] + assert "Authorization" not in token_req.headers + body = form(token_req) + assert body["client_id"] == "test-client-id" + assert body["client_secret"] == "test-client-secret" + + @pytest.mark.anyio async def test_stored_token_is_reused_without_reauthorizing() -> None: """A valid stored token is sent on the first request; on success no ASM or /token is fetched."""