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]