From 15802c37fb6ccb6a61132455c88ddce2ff437501 Mon Sep 17 00:00:00 2001 From: Baldwinzc <56501736+Baldwinzc@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:01:38 +0800 Subject: [PATCH] fix(mcp): keep grant_type authoritative over extra_token_params (#4860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): keep grant_type authoritative over extra_token_params _fetch_token built the token request body as {"grant_type": oauth.grant_type, **oauth.extra_token_params}, so an operator-supplied extra_token_params that happened to contain "grant_type" silently overwrote the value sent to the token endpoint while the branch logic below still keyed off oauth.grant_type — the sent grant_type and the chosen auth flow would disagree, and the provider would almost certainly reject the request. Spread extra_token_params first and set grant_type (and the other reserved fields, which were already set after the spread) afterward, so operator-supplied params can populate arbitrary extra fields but never override the reserved ones the flow depends on. * test(mcp): cover extra OAuth token parameters --------- Co-authored-by: Willem Jiang --- .../packages/harness/deerflow/mcp/oauth.py | 11 ++-- backend/tests/test_mcp_oauth.py | 52 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/backend/packages/harness/deerflow/mcp/oauth.py b/backend/packages/harness/deerflow/mcp/oauth.py index 632b31093..b757b6455 100644 --- a/backend/packages/harness/deerflow/mcp/oauth.py +++ b/backend/packages/harness/deerflow/mcp/oauth.py @@ -118,10 +118,13 @@ class OAuthTokenManager: async def _fetch_token(self, oauth: McpOAuthConfig) -> _OAuthToken: import httpx # pyright: ignore[reportMissingImports] - data: dict[str, str] = { - "grant_type": oauth.grant_type, - **oauth.extra_token_params, - } + # extra_token_params is spread first so the reserved fields below + # (grant_type, scope, audience, client_id, ...) cannot be silently + # overridden by an operator-supplied key — otherwise the branch logic + # below (which keys off oauth.grant_type) and the value actually sent + # to the token endpoint would disagree. + data: dict[str, str] = dict(oauth.extra_token_params) + data["grant_type"] = oauth.grant_type if oauth.scope: data["scope"] = oauth.scope diff --git a/backend/tests/test_mcp_oauth.py b/backend/tests/test_mcp_oauth.py index da9b5f6db..c339b9a58 100644 --- a/backend/tests/test_mcp_oauth.py +++ b/backend/tests/test_mcp_oauth.py @@ -86,6 +86,58 @@ def test_oauth_token_manager_fetches_and_caches_token(monkeypatch): assert post_calls[0]["data"]["grant_type"] == "client_credentials" +def test_oauth_extra_token_params_cannot_override_grant_type(monkeypatch): + post_calls: list[dict[str, Any]] = [] + + def _client_factory(*args, **kwargs): + return _MockAsyncClient( + payload={ + "access_token": "token-123", + "token_type": "Bearer", + "expires_in": 3600, + }, + post_calls=post_calls, + **kwargs, + ) + + monkeypatch.setattr("httpx.AsyncClient", _client_factory) + + config = ExtensionsConfig.model_validate( + { + "mcpServers": { + "secure-http": { + "enabled": True, + "type": "http", + "url": "https://api.example.com/mcp", + "oauth": { + "enabled": True, + "token_url": "https://auth.example.com/oauth/token", + "grant_type": "client_credentials", + "client_id": "client-id", + "client_secret": "client-secret", + # A careless copy-paste from another OAuth config. + "extra_token_params": { + "grant_type": "password", + "resource": "https://api.example.com", + }, + }, + } + } + } + ) + + manager = OAuthTokenManager.from_extensions_config(config) + + asyncio.run(manager.get_authorization_header("secure-http")) + + # The reserved grant_type must win over the operator-supplied param so + # the value sent to the token endpoint matches the branch logic that + # picked client_credentials below. Other extension params still pass + # through unchanged. + assert post_calls[0]["data"]["grant_type"] == "client_credentials" + assert post_calls[0]["data"]["resource"] == "https://api.example.com" + + def test_build_oauth_interceptor_injects_authorization_header(monkeypatch): post_calls: list[dict[str, Any]] = []