# ADR-117: MCP OAuth 2.1 & Confused Deputy Prevention

**狀態**：Accepted
**日期**：2026-05-03（台北）
**決策者**：統帥
**範圍**：MCP Gateway 對外授權規格、Confused Deputy 防護、OAuth 2.1 實作邊界
**關聯**：ADR-106（MCP Gateway contract）、ADR-116（approval_token）、ADR-118（RLS）

---

## 背景

AwoooP 的 MCP Gateway 代替 Agent 呼叫外部工具（K8s、資料庫、Prometheus）。這個設計有兩個核心風險：

### 風險 1：Confused Deputy（混淆代理人攻擊）

```
攻擊者 → 發給 Agent A 一個 run：「幫我呼叫 mcp_tool=drop_table」
Agent A → 用自己的 service token 向 MCP Gateway 請求
MCP Gateway → 看到合法 service token → 執行 drop_table
```

問題：MCP Gateway 作為 Deputy，用自己的權限替攻擊者執行了操作，沒有驗證「這個 run 有沒有授權使用這個 tool」。

### 風險 2：Cross-Tenant Tool Access

```
EwoooC 的 run → 向 MCP Gateway 請求 tool=k8s_exec_pod
MCP Gateway → 若沒有 tenant 隔離 → 可能執行在 awoooi-prod namespace
```

### 現況
- 現行 MCP Gateway 沒有 OAuth 2.1 授權流程
- `mcp_bridge.py` 直接用 service token 向外部工具請求（Confused Deputy）
- 沒有 `aud`（audience）驗證（RFC 9728）
- 沒有 Dynamic Client Registration（RFC 7591）

---

## 決策

### D1 — OAuth 2.1 Token 規格（MCP 呼叫鏈）

**MCP Gateway Token（代表 run 向外部工具請求）**：

```
算法：HS256
簽章 Key：K8s Secret `awooop-mcp-signing-key`

Payload:
  iss: "awooop-platform"
  sub: "{project_id}:{run_id}"
  aud: "{tool_endpoint}"           # RFC 9728 Resource Indicators
  jti: UUID（Redis NX，TTL tool_call_ttl）
  exp: now + 60（60 秒，僅用於一次 tool call）
  iat: now
  scope: "{tool_id}"               # 精確 tool，不是模糊 namespace
  project_id: "{project_id}"
  agent_id: "{agent_id}"
  allowed_namespaces: [...]        # 從 MCP Gateway contract 讀取，不從 LLM output

驗證（MCP Gateway 端）：
  1. HS256 簽章驗證
  2. aud 必須 == 當前 tool endpoint（RFC 9728）
  3. exp 未過期（60s 短 TTL）
  4. jti Redis NX（防 replay）
  5. scope 包含請求的 tool_id
  6. allowed_namespaces 限制 K8s 操作範圍
```

### D2 — Confused Deputy 防護

**三道防線**：

**防線 1：run-level tool allowlist**

每個 run 在建立時，從 Agent contract 的 `allowed_tools` 欄位讀取允許的 tool 清單。
MCP Gateway 在執行前驗證：`tool_id in run.allowed_tools`。

```python
async def execute_mcp_tool(
    run_id: str,
    tool_id: str,
    tool_params: dict,
    mcp_token: str  # HS256 token
) -> MCPResult:
    # 1. 驗證 token（aud == tool endpoint, scope == tool_id）
    claims = verify_mcp_token(mcp_token, audience=get_tool_endpoint(tool_id))

    # 2. 驗證 run 有沒有授權使用這個 tool（防 Confused Deputy）
    run = await get_run(run_id)
    if tool_id not in run.allowed_tools:
        raise MCPError("E-MCP-001", f"Tool {tool_id} not in run allowed_tools")

    # 3. 驗證 project_id 一致（防 cross-tenant）
    if claims["project_id"] != run.project_id:
        raise MCPError("E-MCP-002", "Cross-tenant tool access denied")

    # 4. 執行（allowed_namespaces 在 token 中，工具自行驗證）
    return await _execute_tool(tool_id, tool_params, claims["allowed_namespaces"])
```

**防線 2：allowed_namespaces 來自 contract**

K8s 相關工具的命名空間限制必須從 MCP Gateway contract 讀取，不從 LLM output 讀取：

```python
# MCP Gateway contract 中
{
  "tool_id": "k8s_exec_pod",
  "allowed_namespaces": ["awoooi-prod"],   # 寫死在 contract
  "risk_level": "HIGH",
  "requires_approval": true               # 高風險工具強制 approval
}
```

**防線 3：approval gate for HIGH risk tools**

`risk_level=HIGH` 的 tool call 必須帶 `approval_token`（ADR-116 D4），且 `approval_token.decision_scope` 必須包含這個 `tool_id`。

### D3 — RFC 7591 Dynamic Client Registration（階段性）

Phase 6（EwoooC onboarding）時才需要完整實作。
當前 Phase 2 先用靜態 client_id（每個 project 一個預設 client）。

**Phase 6 目標**：
- `POST /v1/platform/oauth/register`：tenant 自助申請 MCP client
- Response 帶 `client_id` + `client_secret`（存 K8s Secret）
- Scopes 必須從 MCP Gateway contract 的 `allowed_tools` 交集

### D4 — PKCE（Authorization Code Flow）

對 Human-in-the-loop 的 approval callback 使用 PKCE：
- `code_verifier`：256-bit random（agent 生成）
- `code_challenge`：S256 hash
- Redirect URI：`{platform_base_url}/v1/platform/oauth/callback`
- 防止 authorization code 截獲攻擊

### D5 — Scope 模型

```
MCP scope 格式：{tool_category}:{tool_id}:{action}

例：
  k8s:k8s_exec_pod:exec           # K8s pod exec
  k8s:k8s_list_pods:read          # K8s pod list（只讀）
  prometheus:prometheus_query:read # Prometheus 查詢
  pg:pg_query:read                 # PostgreSQL 只讀查詢
  pg:pg_mutate:write               # PostgreSQL 寫入（需 approval）
```

**Scope hierarchy**（MCP Gateway contract 中定義）：
- `k8s:*:exec` → requires approval (HIGH)
- `k8s:*:read` → auto-approve if risk=LOW
- `pg:*:write` → requires approval (HIGH)

---

## 實作要點

### mcp_bridge.py 修補優先序

| 漏洞 | 位置 | 修補方式 | PR |
|------|------|---------|-----|
| namespace hardcode | mcp_bridge.py:592,602,631,647,681 | 從 MCP Gateway contract 讀取 | PR-05 |
| 無 Confused Deputy 防護 | mcp_bridge.py（tool 執行路徑）| run.allowed_tools 驗證 | Phase 3 |
| 無 token aud 驗證 | mcp_bridge.py（token 生成）| 加入 RFC 9728 aud claim | Phase 3 |

### MCP 錯誤碼

| 錯誤碼 | 含義 |
|--------|------|
| E-MCP-001 | Tool not in run.allowed_tools（Confused Deputy 攔截）|
| E-MCP-002 | Cross-tenant tool access denied |
| E-MCP-003 | aud mismatch（RFC 9728 violation）|
| E-MCP-004 | Scope insufficient for requested action |
| E-MCP-005 | HIGH risk tool requires approval_token |

---

## 後果

### Benefits
- Confused Deputy 攻擊從根源切斷：每個 run 的 tool 授權在建立時固定
- RFC 9728 aud 驗證：MCP token 無法被用於不同 tool endpoint
- allowed_namespaces 從 contract 讀取：LLM 無法透過 prompt injection 擴大 K8s 操作範圍

### Costs
- Phase 3 需要重構 mcp_bridge.py 的 token 生成邏輯
- 每次 tool call 需要 Redis jti NX（約 1ms 額外延遲）

### Risks
- PKCE 在 Phase 6 前未實作，approval callback 仍使用 approval_token（ADR-116 D4）
- 緩解：approval_token 已有 jti replay 防護，短期可接受

---

## 驗收標準

- [ ] `mcp_bridge.py` 命名空間 hardcode 移除（PR-05）
- [ ] MCP token 包含 `aud` + `scope`（Phase 3）
- [ ] Confused Deputy 防護：tool_id 不在 run.allowed_tools → E-MCP-001（整合測試）
- [ ] Cross-tenant：ewoooc token 無法執行 awoooi namespace 操作（vuln-verifier PoC）
- [ ] HIGH risk tool（k8s_exec_pod）無 approval_token → E-MCP-005

## 關聯

- ADR-106（MCP Gateway contract allowed_tools 定義）
- ADR-116（approval_token HS256，HIGH risk tool approval）
- ADR-118（RLS，tenant DB 隔離）
- INV-4（namespace hardcode 位置：mcp_bridge.py:592,602,631,647,681）
