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 917964e  fix: enforce exact external OAuth scopes (#147)
917964e is described below

commit 917964e5e06188c515cc991779f2bdbc8743aa6c
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 12:56:34 2026 +0800

    fix: enforce exact external OAuth scopes (#147)
---
 CHANGELOG.md                                 |   3 +
 README.md                                    |  10 +-
 doris_mcp_server/auth/mcp_auth_middleware.py |  23 +-
 doris_mcp_server/auth/operation_policy.py    |  52 +++-
 test/security/test_auth_cross_matrix.py      | 373 +++++++++++++++++++++++++++
 5 files changed, 457 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ee99c6..1b99fa9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -87,6 +87,9 @@ under **Unreleased** until a new version is selected and 
published.
 - Preserved external OAuth token failures through the authentication boundary
   and returned RFC 6750/RFC 9728 Bearer challenges, protected-resource
   metadata, and operation-specific insufficient-scope responses.
+- Enforced exact external OAuth scopes across tool, resource, and Prompt
+  operations before modern HTTP dispatch while preserving static token, JWT,
+  anonymous loopback, and local stdio behavior.
 - 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
diff --git a/README.md b/README.md
index cec24d3..025e269 100644
--- a/README.md
+++ b/README.md
@@ -799,7 +799,7 @@ OAUTH_AUDIENCE=https://mcp.example.com/mcp
 OAUTH_INTROSPECTION_URL=https://issuer.example.com/introspect
 OAUTH_USERINFO_URL=https://issuer.example.com/userinfo
 
-OAUTH_SCOPE="tool:list resource:list resource:read"
+OAUTH_SCOPE="tool:list tool:call:exec_query resource:list resource:read"
 OAUTH_REQUIRED_SCOPE="tool:list resource:read"
 ```
 
@@ -820,6 +820,14 @@ not copied into these responses. These HTTP OAuth 
challenges do not apply to
 stdio transport, where credentials are supplied through the local process
 environment.
 
+External OAuth operation scopes are exact: listing tools requires `tool:list`,
+calling a tool requires `tool:call:<tool-name>`, listing and reading resources
+require `resource:list` and `resource:read`, and Prompt operations require
+`prompt:list` and `prompt:get`. Add every callable tool scope to
+`OAUTH_SCOPE`; `*` and unrelated scopes never satisfy an operation check.
+Static token, JWT, anonymous loopback, and local stdio authorization keep
+their existing non-OAuth permission behavior.
+
 ### Doris-Backed OAuth Authentication
 
 Doris-backed OAuth is a separate OAuth mode where Doris itself is the 
authorization backend. The MCP client discovers this server's OAuth metadata, 
the user signs in with a Doris username and password, the server validates 
those credentials by creating a per-user Doris connection pool, and issued 
`doa_` access tokens route tool calls through that Doris user's pool. MCP 
scopes control which MCP operations can be called; Doris RBAC controls which 
catalogs, databases, tables, and metadata t [...]
diff --git a/doris_mcp_server/auth/mcp_auth_middleware.py 
b/doris_mcp_server/auth/mcp_auth_middleware.py
index 7e4bb00..19aca12 100644
--- a/doris_mcp_server/auth/mcp_auth_middleware.py
+++ b/doris_mcp_server/auth/mcp_auth_middleware.py
@@ -19,6 +19,7 @@
 
 from typing import Any
 
+from starlette.datastructures import Headers
 from starlette.responses import JSONResponse
 from starlette.types import ASGIApp, Receive, Scope, Send
 
@@ -40,10 +41,27 @@ from .oauth_resource import (
     external_oauth_error_response,
     external_oauth_insufficient_scope_response,
 )
-from .operation_policy import OperationAuthorizationError
+from .operation_policy import OperationAuthorizationError, authorize_operation
 
 logger = get_logger(__name__)
 
+_HTTP_METHOD_OPERATIONS = {
+    "tools/list": "list_tools",
+    "resources/list": "list_resources",
+    "resources/read": "read_resource",
+    "prompts/list": "list_prompts",
+    "prompts/get": "get_prompt",
+}
+
+
+def _operation_from_http_scope(scope: Scope) -> str | None:
+    headers = Headers(scope=scope)
+    method = headers.get("Mcp-Method")
+    if method == "tools/call":
+        tool_name = headers.get("Mcp-Name")
+        return f"tool:{tool_name}" if tool_name else None
+    return _HTTP_METHOD_OPERATIONS.get(method or "")
+
 
 async def extract_bearer_credentials_from_scope(
     scope: Scope,
@@ -133,6 +151,9 @@ class MCPAuthASGIMiddleware:
             return
 
         try:
+            operation = _operation_from_http_scope(scoped_request)
+            if operation is not None:
+                authorize_operation(auth_context, operation)
             await self.downstream(scoped_request, receive, send)
         except OperationAuthorizationError as exc:
             body = redact_error_payload(exc.to_dict())
diff --git a/doris_mcp_server/auth/operation_policy.py 
b/doris_mcp_server/auth/operation_policy.py
index 28ebf06..7c5fba1 100644
--- a/doris_mcp_server/auth/operation_policy.py
+++ b/doris_mcp_server/auth/operation_policy.py
@@ -15,7 +15,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-"""MCP operation policy for Doris OAuth."""
+"""MCP operation policy for scope-aware OAuth authentication."""
 
 from dataclasses import dataclass
 from typing import Any
@@ -324,6 +324,14 @@ OPERATION_POLICIES: dict[str, OperationPolicy] = {
     "http:/token/management": OperationPolicy("http:/token/management", None, 
"deny", "token_admin", "high"),
 }
 
+EXTERNAL_OAUTH_OPERATION_SCOPES: dict[str, str] = {
+    "list_tools": "tool:list",
+    "list_resources": "resource:list",
+    "read_resource": "resource:read",
+    "list_prompts": "prompt:list",
+    "get_prompt": "prompt:get",
+}
+
 
 def resolve_operation_policy(operation: str, auth_context: Any = None) -> 
OperationPolicy:
     if operation.startswith("tool:"):
@@ -341,11 +349,51 @@ def _has_scope(auth_context: Any, required_scope: str | 
None) -> bool:
     return required_scope in scopes
 
 
+def _external_oauth_required_scope(operation: str) -> str:
+    if operation.startswith("tool:"):
+        tool_name = operation.split(":", 1)[1]
+        if policy_definition_for_tool(tool_name) is None:
+            raise OperationAuthorizationError(
+                f"Unknown MCP operation: {operation}",
+                status_code=403,
+                error_code="UNKNOWN_OPERATION",
+                required_scope=f"tool:call:{tool_name}",
+                operation=operation,
+            )
+        return f"tool:call:{tool_name}"
+
+    required_scope = EXTERNAL_OAUTH_OPERATION_SCOPES.get(operation)
+    if required_scope is None:
+        raise OperationAuthorizationError(
+            f"Unknown MCP operation: {operation}",
+            status_code=403,
+            error_code="UNKNOWN_OPERATION",
+            operation=operation,
+        )
+    return required_scope
+
+
 def authorize_operation(auth_context: Any | None, operation: str) -> None:
-    """Authorize an operation for Doris OAuth. Legacy auth methods pass 
through."""
+    """Authorize scope-aware OAuth operations.
+
+    Static token, JWT, anonymous, and local stdio paths retain their existing
+    authorization behavior outside this OAuth scope policy.
+    """
     if auth_context is None:
         return
 
+    if auth_context.auth_method == "external_oauth":
+        required_scope = _external_oauth_required_scope(operation)
+        if not _has_scope(auth_context, required_scope):
+            raise OperationAuthorizationError(
+                f"Missing required scope: {required_scope}",
+                status_code=403,
+                error_code="PERMISSION_DENIED",
+                required_scope=required_scope,
+                operation=operation,
+            )
+        return
+
     if auth_context.auth_method != "doris_oauth":
         return
 
diff --git a/test/security/test_auth_cross_matrix.py 
b/test/security/test_auth_cross_matrix.py
new file mode 100644
index 0000000..c851c22
--- /dev/null
+++ b/test/security/test_auth_cross_matrix.py
@@ -0,0 +1,373 @@
+from __future__ import annotations
+
+import sys
+from collections.abc import Iterable
+from pathlib import Path
+from typing import Any
+
+import httpx2
+import pytest
+from mcp import Client, StdioServerParameters
+from mcp.client.stdio import stdio_client
+
+from doris_mcp_server.auth.mcp_auth_middleware import MCPAuthASGIMiddleware
+from doris_mcp_server.auth.operation_policy import (
+    OperationAuthorizationError,
+    authorize_operation,
+)
+from doris_mcp_server.http_transport import DorisMCPHTTPTransport
+from doris_mcp_server.protocol import create_transport_security
+from doris_mcp_server.tools.tool_registry import (
+    DORIS_OAUTH_EXPLAIN_TOOL_SET,
+    DORIS_OAUTH_METADATA_TOOL_SET,
+    DORIS_OAUTH_QUERY_TOOL_SET,
+    RESTRICTED_TOOL_NAMES,
+)
+from doris_mcp_server.utils.auth_credentials import BearerCredentials
+from doris_mcp_server.utils.config import EffectiveAuthConfig
+from doris_mcp_server.utils.security import AuthContext
+from test.protocol.tool_registry_server import create_registry_test_server
+
+BASE_OPERATION_SCOPES = (
+    ("list_tools", "tool:list"),
+    ("list_resources", "resource:list"),
+    ("read_resource", "resource:read"),
+    ("list_prompts", "prompt:list"),
+    ("get_prompt", "prompt:get"),
+)
+ALL_TOOL_NAMES = tuple(
+    sorted(
+        DORIS_OAUTH_METADATA_TOOL_SET
+        | DORIS_OAUTH_QUERY_TOOL_SET
+        | DORIS_OAUTH_EXPLAIN_TOOL_SET
+        | RESTRICTED_TOOL_NAMES
+    )
+)
+TOOL_OPERATION_SCOPES = tuple(
+    (f"tool:{tool_name}", f"tool:call:{tool_name}")
+    for tool_name in ALL_TOOL_NAMES
+)
+ALL_OPERATION_SCOPES = BASE_OPERATION_SCOPES + TOOL_OPERATION_SCOPES
+
+
+def _context(auth_method: str, scopes: Iterable[str] = ()) -> AuthContext:
+    context = AuthContext(
+        user_id=f"{auth_method}-user",
+        auth_method=auth_method,
+        oauth_scopes=list(scopes),
+    )
+    if auth_method == "doris_oauth":
+        context.doris_oauth_db_tools_enabled = True
+        context.doris_oauth_db_tool_allowlist = tuple(
+            sorted(DORIS_OAUTH_METADATA_TOOL_SET)
+        )
+        context.doris_oauth_query_tools_enabled = True
+        context.doris_oauth_query_tool_allowlist = tuple(
+            sorted(DORIS_OAUTH_QUERY_TOOL_SET)
+        )
+        context.doris_oauth_explain_tools_enabled = True
+        context.doris_oauth_explain_tool_allowlist = tuple(
+            sorted(DORIS_OAUTH_EXPLAIN_TOOL_SET)
+        )
+    return context
+
+
+def _external_oauth_config() -> EffectiveAuthConfig:
+    return EffectiveAuthConfig(
+        enable_token_auth=False,
+        enable_jwt_auth=False,
+        enable_external_oauth_auth=True,
+        enable_doris_oauth_auth=False,
+        auth_methods=("external_oauth",),
+        oauth_discovery_mode="external_oauth",
+        transport="http",
+        requested_workers=1,
+        effective_workers=1,
+        legacy_auth_type="",
+        external_oauth_issuer="https://issuer.example.test";,
+        external_oauth_resource="https://mcp.example.test/mcp";,
+        external_oauth_scopes=(
+            "tool:list",
+            "tool:call:exec_query",
+        ),
+        external_oauth_required_scopes=("tool:list",),
+    )
+
+
+def _modern_request(
+    request_id: int,
+    method: str,
+    *,
+    name: str | None = None,
+    arguments: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    params: dict[str, Any] = {
+        "_meta": {
+            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
+            "io.modelcontextprotocol/clientCapabilities": {},
+            "io.modelcontextprotocol/clientInfo": {
+                "name": "auth-cross-matrix-test",
+                "version": "1.0.0",
+            },
+        }
+    }
+    if name is not None:
+        params["name"] = name
+        params["arguments"] = arguments or {}
+    return {
+        "jsonrpc": "2.0",
+        "id": request_id,
+        "method": method,
+        "params": params,
+    }
+
+
+def _modern_headers(
+    method: str,
+    *,
+    name: str | None = None,
+    token: str,
+) -> dict[str, str]:
+    headers = {
+        "Accept": "application/json, text/event-stream",
+        "Authorization": f"Bearer {token}",
+        "Content-Type": "application/json",
+        "Mcp-Protocol-Version": "2026-07-28",
+        "Mcp-Method": method,
+    }
+    if name is not None:
+        headers["Mcp-Name"] = name
+    return headers
+
+
[email protected](
+    "auth_context",
+    [
+        None,
+        _context("anonymous"),
+        _context("token"),
+        _context("jwt"),
+    ],
+    ids=["stdio-no-context", "anonymous", "token", "jwt"],
+)
[email protected](
+    "operation",
+    [operation for operation, _ in ALL_OPERATION_SCOPES],
+)
+def test_non_oauth_auth_methods_do_not_apply_oauth_scope_policy(
+    auth_context: AuthContext | None,
+    operation: str,
+) -> None:
+    authorize_operation(auth_context, operation)
+
+
[email protected](("operation", "required_scope"), ALL_OPERATION_SCOPES)
+def test_external_oauth_accepts_each_exact_operation_scope(
+    operation: str,
+    required_scope: str,
+) -> None:
+    authorize_operation(
+        _context("external_oauth", [required_scope]),
+        operation,
+    )
+
+
[email protected](("operation", "required_scope"), ALL_OPERATION_SCOPES)
[email protected](
+    "granted_scopes",
+    [
+        (),
+        ("*",),
+        ("wrong:scope",),
+    ],
+    ids=["missing", "wildcard", "wrong"],
+)
+def test_external_oauth_rejects_missing_or_inexact_operation_scope(
+    operation: str,
+    required_scope: str,
+    granted_scopes: tuple[str, ...],
+) -> None:
+    with pytest.raises(OperationAuthorizationError) as exc:
+        authorize_operation(
+            _context("external_oauth", granted_scopes),
+            operation,
+        )
+
+    assert exc.value.error_code == "PERMISSION_DENIED"
+    assert exc.value.required_scope == required_scope
+    assert exc.value.operation == operation
+
+
[email protected](
+    ("operation", "required_scope"),
+    (
+        BASE_OPERATION_SCOPES[:3]
+        + tuple(
+            (f"tool:{tool_name}", f"tool:call:{tool_name}")
+            for tool_name in sorted(
+                DORIS_OAUTH_METADATA_TOOL_SET
+                | DORIS_OAUTH_QUERY_TOOL_SET
+                | DORIS_OAUTH_EXPLAIN_TOOL_SET
+            )
+        )
+    ),
+)
+def test_doris_oauth_accepts_enabled_operations_with_exact_scope(
+    operation: str,
+    required_scope: str,
+) -> None:
+    authorize_operation(
+        _context("doris_oauth", [required_scope]),
+        operation,
+    )
+
+
[email protected](
+    "operation",
+    ["list_prompts", "get_prompt"]
+    + [f"tool:{tool_name}" for tool_name in sorted(RESTRICTED_TOOL_NAMES)],
+)
+def test_doris_oauth_keeps_unsupported_operations_denied(
+    operation: str,
+) -> None:
+    required_scope = (
+        f"tool:call:{operation.split(':', 1)[1]}"
+        if operation.startswith("tool:")
+        else "prompt:list"
+        if operation == "list_prompts"
+        else "prompt:get"
+    )
+    with pytest.raises(OperationAuthorizationError) as exc:
+        authorize_operation(
+            _context("doris_oauth", [required_scope]),
+            operation,
+        )
+
+    assert exc.value.error_code == "UNSUPPORTED_FOR_DORIS_OAUTH"
+
+
+def test_external_oauth_rejects_unknown_tool_before_dispatch() -> None:
+    with pytest.raises(OperationAuthorizationError) as exc:
+        authorize_operation(
+            _context("external_oauth", ["tool:call:not_real"]),
+            "tool:not_real",
+        )
+
+    assert exc.value.error_code == "UNKNOWN_OPERATION"
+    assert exc.value.operation == "tool:not_real"
+
+
[email protected]
+async def 
test_streamable_http_enforces_external_oauth_tool_scope_before_dispatch() -> 
None:
+    contexts = {
+        "list-only": _context("external_oauth", ["tool:list"]),
+        "exec-query": _context(
+            "external_oauth",
+            ["tool:list", "tool:call:exec_query"],
+        ),
+    }
+
+    class SecurityManager:
+        async def authenticate_request(
+            self,
+            credentials: BearerCredentials,
+        ) -> AuthContext:
+            return contexts[credentials.token]
+
+    server = create_registry_test_server()
+    transport = DorisMCPHTTPTransport(
+        app=server,
+        security_settings=create_transport_security("127.0.0.1"),
+    )
+    app = MCPAuthASGIMiddleware(
+        SecurityManager(),
+        transport.handle_request,
+        _external_oauth_config(),
+    )
+
+    async with (
+        transport.run(),
+        httpx2.ASGITransport(app) as asgi_transport,
+        httpx2.AsyncClient(
+            transport=asgi_transport,
+            base_url="http://127.0.0.1:3000";,
+        ) as client,
+    ):
+        listed = await client.post(
+            "/mcp",
+            json=_modern_request(1, "tools/list"),
+            headers=_modern_headers("tools/list", token="list-only"),
+        )
+        assert listed.status_code == 200
+        assert len(listed.json()["result"]["tools"]) == 25
+
+        denied = await client.post(
+            "/mcp",
+            json=_modern_request(
+                2,
+                "tools/call",
+                name="exec_query",
+                arguments={"sql": "SELECT 1"},
+            ),
+            headers=_modern_headers(
+                "tools/call",
+                name="exec_query",
+                token="list-only",
+            ),
+        )
+        assert denied.status_code == 403
+        assert denied.json()["error"] == "PERMISSION_DENIED"
+        assert denied.json()["required_scope"] == "tool:call:exec_query"
+        assert 'error="insufficient_scope"' in denied.headers[
+            "www-authenticate"
+        ]
+        assert 'scope="tool:call:exec_query"' in denied.headers[
+            "www-authenticate"
+        ]
+
+        allowed = await client.post(
+            "/mcp",
+            json=_modern_request(
+                3,
+                "tools/call",
+                name="exec_query",
+                arguments={"sql": "SELECT 1"},
+            ),
+            headers=_modern_headers(
+                "tools/call",
+                name="exec_query",
+                token="exec-query",
+            ),
+        )
+        assert allowed.status_code == 200
+        assert allowed.json()["result"]["structuredContent"][
+            "registry_dispatch"
+        ] is True
+
+
[email protected]
+async def 
test_true_subprocess_stdio_keeps_local_tool_and_resource_paths_scope_free() -> 
None:
+    server_script = (
+        Path(__file__).resolve().parents[1]
+        / "protocol"
+        / "tool_registry_server.py"
+    )
+    server_params = StdioServerParameters(
+        command=sys.executable,
+        args=[str(server_script)],
+    )
+
+    async with Client(stdio_client(server_params)) as client:
+        tools = await client.list_tools(cache_mode="bypass")
+        assert len(tools.tools) == 25
+
+        called = await client.call_tool("exec_query", {"sql": "SELECT 1"})
+        assert called.structured_content["registry_dispatch"] is True
+
+        resources = await client.list_resources(cache_mode="bypass")
+        assert resources.resources == []
+        read = await client.read_resource(
+            "doris://table/orders",
+            cache_mode="bypass",
+        )
+        assert read.contents[0].uri == "doris://table/orders"


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to