This is an automated email from the ASF dual-hosted git repository.

FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git


The following commit(s) were added to refs/heads/master by this push:
     new 6500b47  fix: bind authorization code exchanges to resource (#121)
6500b47 is described below

commit 6500b471041a6ad8a3051d6f0a532ebab238769b
Author: Yijia Su <[email protected]>
AuthorDate: Wed Jul 29 22:01:53 2026 +0800

    fix: bind authorization code exchanges to resource (#121)
---
 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]

Reply via email to