This is an automated email from the ASF dual-hosted git repository. FreeOnePlus pushed a commit to branch agent/sec-012-token-resource-validation in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
commit 1ea4f81692c2a0b9664445de536096cf28dfe968 Author: FreeOnePlus <[email protected]> AuthorDate: Wed Jul 29 22:00:44 2026 +0800 fix: bind authorization code exchanges to resource --- CHANGELOG.md | 2 + README.md | 5 ++- doris_mcp_server/auth/doris_oauth_provider.py | 6 +++ test/auth/test_doris_oauth_routes.py | 57 +++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c95c49..448d9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,8 @@ under **Unreleased** until a new version is selected and published. metadata, and operation-specific insufficient-scope responses. - Bound Doris OAuth access tokens to the canonical MCP resource and rejected tokens issued for any other resource before per-user pool access. +- Rejected Doris OAuth authorization-code exchanges whose required RFC 8707 + `resource` is missing or differs from the authorization grant. - Required Doris OAuth DCR clients to declare a persisted `application_type` and enforced type-matched redirect URI rules for native and web clients. - Added RFC 9207 `iss` identification to Doris OAuth authorization success and diff --git a/README.md b/README.md index 12cb5f6..d0e634b 100644 --- a/README.md +++ b/README.md @@ -683,7 +683,10 @@ Each Doris OAuth token record is bound to the resource selected during authorization. The protected MCP endpoint accepts only the exact canonical resource `${DORIS_OAUTH_BASE_URL}/mcp`; a token issued for the authorization server or any other resource is rejected with an `invalid_token` challenge -before a per-user Doris pool is used. +before a per-user Doris pool is used. Clients must send that canonical +`resource` in both the authorization request and authorization-code token +request. The token endpoint returns RFC 8707 `invalid_target` if the value is +missing or does not exactly match the resource bound to the authorization code. #### Minimal Local Configuration diff --git a/doris_mcp_server/auth/doris_oauth_provider.py b/doris_mcp_server/auth/doris_oauth_provider.py index b328e54..28bc2c2 100644 --- a/doris_mcp_server/auth/doris_oauth_provider.py +++ b/doris_mcp_server/auth/doris_oauth_provider.py @@ -291,6 +291,12 @@ class DorisOAuthProvider: raise TokenEndpointError("invalid_grant", "Authorization code is invalid or expired", status_code=400) if not self._verify_pkce(record.code_challenge, payload.get("code_verifier") or ""): raise TokenEndpointError("invalid_grant", "Authorization code is invalid or expired", status_code=400) + if payload.get("resource") != record.resource: + raise TokenEndpointError( + "invalid_target", + "resource is required and must match the authorization grant", + status_code=400, + ) if payload.get("scope"): requested = set(self.scope_policy.parse_scope(payload.get("scope"))) if not requested <= set(record.scopes): diff --git a/test/auth/test_doris_oauth_routes.py b/test/auth/test_doris_oauth_routes.py index dee8c83..625dcc7 100644 --- a/test/auth/test_doris_oauth_routes.py +++ b/test/auth/test_doris_oauth_routes.py @@ -660,6 +660,7 @@ async def test_authorize_invalid_redirect_is_direct_400_and_invalid_scope_redire "state": "state-1", "code_challenge": challenge, "code_challenge_method": "S256", + "resource": provider.resource, }, ) assert response.status_code == 400 @@ -807,6 +808,7 @@ async def test_full_login_code_exchange_auth_context_and_pool_missing_revocation "code": code, "redirect_uri": "http://localhost:7777/callback", "code_verifier": verifier, + "resource": provider.resource, }, ) assert token_response.status_code == 200 @@ -869,6 +871,59 @@ async def test_access_token_for_other_resource_is_rejected_before_pool_access(): assert provider.store.get_access_token(pair.access_token).last_used_at is None [email protected]( + "token_resource", + [ + pytest.param(None, id="missing"), + pytest.param("http://localhost:3000", id="different-resource"), + ], +) [email protected] +async def test_authorization_code_exchange_rejects_unbound_resource( + token_resource, +): + provider, _cm, app = _provider_app() + client_record = provider.store.add_client( + client_id="resource-exchange-client", + client_secret=None, + token_endpoint_auth_method="none", + application_type="native", + redirect_uris=("http://localhost:7777/callback",), + client_allowed_scopes=("tool:list",), + source="dcr", + expires_at=None, + ) + challenge, verifier = _pkce() + code, _record = provider.store.create_authorization_code( + client_id=client_record.client_id, + doris_user="alice", + redirect_uri="http://localhost:7777/callback", + scopes=("tool:list",), + resource=provider.resource, + code_challenge=challenge, + code_challenge_method="S256", + ttl_seconds=300, + ) + payload = { + "grant_type": "authorization_code", + "client_id": client_record.client_id, + "code": code, + "redirect_uri": "http://localhost:7777/callback", + "code_verifier": verifier, + } + if token_resource is not None: + payload["resource"] = token_resource + + async with await _client(app) as client: + response = await client.post("/oauth/token", data=payload) + + assert response.status_code == 400 + assert response.json()["error"] == "invalid_target" + assert provider.store.access_by_hash == {} + assert provider.store.refresh_by_hash == {} + assert provider.store.pop_authorization_code(code) is None + + @pytest.mark.asyncio async def test_full_login_without_scope_grants_configured_rbac_capability_envelope(): provider, cm, app = _provider_app( @@ -894,6 +949,7 @@ async def test_full_login_without_scope_grants_configured_rbac_capability_envelo "state": "state-full-default", "code_challenge": challenge, "code_challenge_method": "S256", + "resource": provider.resource, }, ) assert authorize.status_code == 302 @@ -921,6 +977,7 @@ async def test_full_login_without_scope_grants_configured_rbac_capability_envelo "code": code, "redirect_uri": "http://localhost:7777/callback", "code_verifier": verifier, + "resource": provider.resource, }, ) --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
