Files
Arcrun/mcp/OAUTH.md
T
Claude 5fa3b79a4c fix(mcp): validate/normalize RFC 8707 resource at issuance; store canonical aud
leo review 最後一條:簽發端沒驗證/正規化 resource → 失敗劇本「OAuth 全程成功但每次打 /mcp 401 aud
mismatch」(尾斜線/canonical 變體,錯誤離根因最遠最難 debug)。改在簽發端 fail fast:

- metadata.ts 加 normalizeResource(scheme/host 小寫、去預設 port、path 去尾斜線,與 resourceUri
  canonical 一致)+ resourceMatches。
- /authorize(GET+POST):帶 resource 且正規化後 != canonical → redirect 帶 error=invalid_target
  (redirect_uri 已驗過才 redirect);一律把 canonical resource 存進 code(不存 client 原樣值)。
- /token:帶 resource 且正規化後 != canonical → 400 invalid_target;aud 一律存 canonical
  resourceUri(origin) → 與 partner-auth 嚴格比對 at.aud===resourceUri(origin) 恆一致。

裁決:尾斜線/大小寫等「正規化後等價」的 resource → 接受(存 canonical aud,/mcp 必過),非拒絕——
否則 claude.ai 真送變體會永久授權失敗連不上(把 401 問題換位重現)。只有正規化後真正不同的
resource(別 host/path)才 fail-fast 拒。詳見 OAUTH.md §2。

測試:normalizeResource/resourceMatches 單元 + 尾斜線變體→正常發碼且 aud canonical、別 host→
/authorize redirect invalid_target 不發碼、/token 別 host→400 invalid_target。
mcp vitest 52/52、tsc exit 0。

Refs #15

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
2026-07-07 05:38:35 +00:00

160 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# arcrun-mcp OAuth 2.1 Serverclaude.ai 遠端 connector 安全登入)
> Design 文件。對應 PR `feat/mcp-oauth-server`。實作全在 `mcp/`(改既有 arcrun-mcp 這一顆 worker
> 的框架碼,非新 worker、非應用工作流、無 service binding)。
## 1. 為什麼(安全定調)
MCP 打進 arcrun = 觸及該租戶 **KBDB 全量讀寫**,是個資外泄面,必須有真認證。
**修掉的漏洞**`partner-auth.ts` 舊行為在 `MULTI_TENANT=false`self-hosted)時,把 `Authorization:
Bearer <x>``<x>` 直接當成 `org_namespace` 明碼放行。於是**任何知道 URL 的人送 `Bearer leo`
就能讀寫 leo 的全部資料**。這正是本次要關掉的洞。
**鐵律**:只知道「網址 + 明碼 namespace」的人,必須讀不到任何資料。
## 2. 安全模型(owner secret 怎麼把關)
claude.ai remote connector 走 **OAuth 2.1 + PKCES256**。整條鏈唯一的「人類祕密閘」在
`/authorize` 同意頁:要求輸入 **owner secret**`MCP_OWNER_SECRET`,存 CF Secrets,非 KV、非明碼 var)。
- 祕密**正確** → 才發 authorization code → claude.ai 用 PKCE `code_verifier``access_token`
- 祕密**錯誤 / 未帶** → 不發碼,重顯同意頁(401)。
- `MCP_OWNER_SECRET` **未設**`/authorize` 直接回 503(拒絕在無把關下發碼,不留不安全預設)。
**為何「只知 URL 的人」進不來**:他能打開 `/authorize` 頁、能自己跑 DCR 拿 `client_id`、能發起
PKCE,但**走到發碼那步需要 owner secret**,而 secret 只在 CF Secrets、只有 owner 知道。拿不到 code →
換不到 token → 打 `/mcp` 一律 401。access_token 是唯一被 `/mcp` 接受的 bearer(見 §5 相容決策)。
**縱深防禦**
- **PKCE S256 強制**`/authorize``/token` 都要求 `code_challenge_method=S256``plain`/缺省一律拒。
- **authorization code 一次性 + 極短 TTL600s)**:讀到即從 KV 刪,防重放。
- **redirect_uri 白名單**DCR 無狀態,見 §5):預設只允許 `claude.ai``claude.com``anthropic.com`
(含子網域)+ `localhost``http` 僅限本機。擋 open-redirect/釣魚把 code 送去攻擊者。可用
`MCP_ALLOWED_REDIRECT_HOSTS` 調整。
- **token audience 綁定 + 簽發端驗證(RFC 8707**
- `/authorize``/token` 收到 client 的 `resource` 參數時,**正規化後**(scheme/host 小寫、去預設 port、
path 去尾斜線;與 `metadata.ts resourceUri` 產出的 canonical 一致)必須 == 本 server canonical
`resourceUri(origin)`。**不符則簽發端 fail fast**`/authorize` redirect 帶 `error=invalid_target`
redirect_uri 已驗過才 redirect,否則 400)、`/token` 回 400 `invalid_target`
- **存進 `aud` 的一律是 canonical `resourceUri(origin)`**(不存 client 原樣值)→ 與 partner-auth 的嚴格比對
`at.aud === resourceUri(origin)` 恆一致。
- **為何要正規化而非純字串拒**:claude.ai 送 `…/mcp/`(尾斜線)或大小寫變體是等價 canonical → 正規化後接受、
存 canonical aud → `/mcp` 必過。若對等價形也拒,claude.ai 真送變體會**永久授權失敗連不上**(把「OAuth 成功
但 /mcp 401」的問題換位重現)。只有正規化後**真正不同**的 resource(別的 host/path)才 fail-fast 拒。
- **KV key 用 SHA-256 hash**codetoken 不以明碼當 key,KV list 也拿不到可用憑證(比照
`wasi-shim.ts` 只寫短效 oauth2 cache 的精神)。
- **owner secretstatic token 用常數時間比對**:防 timing attack。
## 3. Endpoint 清單(全掛在 worker origin 根,非 `/mcp` basePath
| Method | Path | 規範 | 作用 |
|---|---|---|---|
| GET | `/.well-known/oauth-protected-resource`+`/mcp` 後綴變體) | RFC 9728 | Protected Resource Metadata`resource` + `authorization_servers` |
| GET | `/.well-known/oauth-authorization-server`+`/mcp` 後綴變體) | RFC 8414 | AS Metadataauthorize/token/registration endpoint、`S256``code``none` |
| POST | `/register` | RFC 7591 | Dynamic Client Registration → 回 `client_id`public client,無 secret |
| GET | `/authorize` | OAuth 2.1 | 呈現 owner-secret 同意頁(要求 `response_type=code` + PKCE S256 + 合法 redirect_uri |
| POST | `/authorize` | OAuth 2.1 | 驗 owner secret → 發 code → 302 redirect 回 `redirect_uri?code=&state=` |
| POST | `/token` | OAuth 2.1 | `authorization_code` + `code_verifier`(PKCE) → `access_token` |
**未帶有效 token 的 `/mcp`(及 GUI REST 端點)**:回 **401 + `WWW-Authenticate: Bearer
resource_metadata="<origin>/.well-known/oauth-protected-resource"`**RFC 9728 §5.1),claude.ai 靠這個
發現 OAuth。
metadata 以「當前請求 origin」動態生成 → 同一份碼在 `mcp.arcrun.dev``arcrun-mcp.<sub>.workers.dev`
都正確。
## 4. 各資料存哪(儲存鐵律逐項)
| 資料 | 存哪 | 理由 |
|---|---|---|
| **authorization code** | 短效 KV `OAUTH_KV`key=`oauth:code:<sha256>`,TTL 600s,一次性 | 「取得的暫時性認證」,允許進短效 KV |
| **access token** | 短效 KV `OAUTH_KV`key=`oauth:tok:<sha256>`TTL=`MCP_TOKEN_TTL`(預設 30 天) | 同上;過期自動消失 |
| **owner secret** | **CF Secrets** `MCP_OWNER_SECRET``wrangler secret put`) | 長效機密,非 KV、非明碼 var |
| **static token(相容用)** | **CF Secrets** `MCP_STATIC_TOKEN`(選配) | 長效機密,同上 |
| **DCR client 註冊** | **不落地(無狀態)** | public client 無 secret,非機密;不需持久 → 不塞 KV(守鐵律) |
| **refresh token** | **不實作**(見 §5) | 避免長效機密落地;改短效 access_token + 到期重新授權 |
| owner namespace / token TTL / redirect 白名單 | `[vars]`(非機密設定) | 純設定值 |
## 5. 相容決策(明碼-namespace-bearer 舊路徑怎麼處理)
**預設安全優先,遠端一律走 OAuth。** `partner-auth.ts` 新認證順序:
1. **OAuth access_token**`OAUTH_KV` 查得到)→ 解出綁定 namespace。**遠端 claude.ai 的正規路徑。**
2. **`MCP_STATIC_TOKEN`(真祕密,CF Secret** → 解成 `MCP_OWNER_NAMESPACE`。**本機 CLI / GUI /
本機 Claude Code 的相容路徑**——用「真祕密 token」取代舊「明碼 namespace」,owner 可掌控(CF Secrets)。
3. **官方 SaaS`MULTI_TENANT` 未設/`true`** → KBDB partner-key 驗證,**行為完全不變**。
4. **【預設關,SUNSET】`ALLOW_PLAINTEXT_NAMESPACE="true"`** → 恢復舊明碼路徑。**僅遷移期**,設了等於重開漏洞,正式勿用。
5. 皆不符 → 401 + `WWW-Authenticate`
**明碼 namespace 當 bearer 的舊路徑已從預設移除**(步驟 5 直接 401)。之所以保留步驟 2/4:
- 本機 Claude Code MCP 目前經 `acr mcp-setup``namespace` 寫進 `.mcp.json``Authorization`
header。若硬砍會斷本機整合。**正解=改用 `MCP_STATIC_TOKEN`(真祕密)**`mcp-setup` 寫入真祕密而非
明碼 namespace 屬 CLI 側後續(本 PR 未動 CLI,於報告標為待辦)。
- `ALLOW_PLAINTEXT_NAMESPACE` 只是遷移期的明確 opt-in 逃生門,預設關 = 預設安全。
**逃生門有退場(SUNSET**`ALLOW_PLAINTEXT_NAMESPACE` 只是遷移期暫時相容,**驗收完即刪整段 code path
`Env` 欄位**。移除追蹤:**Gitea issue #18**https://git.uncle6.me/Leo/Arcrun/issues/18)。code 中該分支已標
`SUNSET` 註記。
### access_token TTL 是有意取捨(無 refresh token
**為何不做 refresh token**refresh token 需長效持久化,依鐵律得進 CF Secrets/KBDB 而非 KV,成本與
面積都大。**有意取捨**=改採「較長 TTL 的 access_token + 到期重走 OAuthowner 重輸一次 owner secret)」,
兼顧安全(週期性重認證)與簡潔(無長效機密落地)。
- **預設 `MCP_TOKEN_TTL` = 2592000 秒(30 天)**——本 PR 維持此預設(leo 拍板不改)。
- **可調**`[vars]``MCP_TOKEN_TTL` 即可;**7 天(604800)為更保守選項**(縮短 = 更頻繁重認證 = 更安全但 UX 略煩)。
- **到期行為**KV TTL 到 → token 自動失效 → `/mcp` 回 401 + `WWW-Authenticate` → claude.ai 重走 OAuth
(再輸一次 owner secret)。無 refresh token 故無長效機密落地。
- **後續(per-owner 可調,非本 PR)**TTL 風險偏好交用戶決定——console 設定頁 → 存 KBDB → `/token` 發 token
時讀 per-owner 覆蓋、回退 30 天。追蹤:**Gitea issue #19**https://git.uncle6.me/Leo/Arcrun/issues/19)。
## 6. 測試涵蓋
`mcp/tests/unit/oauth.test.ts`
- PKCE S256 驗證(正確 / 拒 plain / 拒缺省 method / verifier 長度邊界 / 竄改);已知 SHA-256 向量、RFC 7636 附錄範例。
- 短效 KV storeauthorization code 一次性(consume 後失效,防重放)、access token 存取 + `exp` 過期、key 為 hash。
- metadataorigin 推導、Protected Resource / AS Metadata 必要欄位、`WWW-Authenticate` 格式。
- **RFC 8707 resource 正規化/簽發端驗證**`normalizeResource`/`resourceMatches`(尾斜線/大小寫/預設 port
等價、別 host/path 不等價);`/authorize` 尾斜線變體→**正常發碼**且 token aud 為 canonical、
別 host 的 resource → GET/POST `/authorize` redirect `error=invalid_target` 不發碼、`/token` 帶別 host
resource → 400 `invalid_target`
- consent XSS escape(惡意 `state` 不注入)。
- 完整流程整合(Hono `app.request`):well-known 動態 origin、DCR 回 client_id 無 secret、redirect_uri
白名單擋非法 host、GET `/authorize` 要 PKCE、`MCP_OWNER_SECRET` 未設→503、**正確祕密+正確 verifier→
access_token**、**錯誤祕密→401 不發 code**、錯誤 verifier→invalid_grant、重用 code→invalid_grant、
`OAUTH_KV` 未設→503。
- **防 drift**spy KV 攔所有 `put`,斷言對 `OAUTH_KV` 的**每一次 put 都帶 `expirationTtl`> 0**——
防未來有人往這顆 KV 塞長效資料(守儲存鐵律)。
`mcp/tests/unit/partner-auth.test.ts`(改測真實 middleware):
- 無/壞 Authorization → 401 + `WWW-Authenticate`RFC 9728)。
- OAuth token → 解出 namespace;未知 token(明碼)在 self-hosted → **401(洞已補)**
- **RFC 8707 aud 驗證**token `aud` 不等於本次請求 origin 算出的 canonical resource URI → **401 `invalid_token`**
- `MCP_STATIC_TOKEN` 相容路徑通過 / 明碼被擋。
- 官方 SaaS partner-keymock KBDB)行為不變。
- `ALLOW_PLAINTEXT_NAMESPACE` 逃生門開/關。
全部測試綠;`tsc --noEmit` exit 0`wrangler deploy --dry-run` 打包過、`OAUTH_KV` binding 正確識別。
## 7. leo 部署前要做什麼(不在本 PR 內,本 PR 不部署)
1. **建 KV namespace + 填 id**(依部署路徑):
- **CLI 路徑(`acr init` / `acr update`)→ 已自動化**:本 PR 把 `OAUTH_KV` 納入 `deploy.ts`
`REQUIRED_KV_NAMESPACES` → init/update 自動建 namespace(冪等)+ `injectWranglerConfig`
`REPLACE_WITH_REAL_KV_ID` 換成用戶帳號真 id。零手動。
- **手動直推(leo21c wrangler deploymistakes #23 codeload 陷阱下走的路徑)→ 需手動**:
`wrangler kv namespace create OAUTH_MCP` → 把 id 貼進 `mcp/wrangler.toml``[[kv_namespaces]] OAUTH_KV`
2. **設 owner secretCF Secrets**`wrangler secret put MCP_OWNER_SECRET`(輸入只有你知道的強祕密)。
3.(選配)**設本機相容 static token**`wrangler secret put MCP_STATIC_TOKEN`,並把本機 `.mcp.json`
`Authorization: Bearer <此值>`(取代舊明碼 namespace)。
4.(選配)`[vars]` 調 `MCP_OWNER_NAMESPACE`(預設 leo/`MCP_TOKEN_TTL`(預設 2592000/
`MCP_ALLOWED_REDIRECT_HOSTS`
5. **重部署 arcrun-mcp**(走 leo21c wrangler 直推,勿 `acr update`——codeload 陷阱 mistakes #23)。
6. 驗收:`curl <origin>/.well-known/oauth-protected-resource` → 200;未帶 token 打 `/mcp` → 401 帶
`WWW-Authenticate`claude.ai 加 remote connector 走完 OAuth(輸 owner secret)能連上。
> ⚠️ 若 KV/secret 未就緒:OAuth 端點誠實回 503、`/mcp` 回 401(不假綠);既有官方 SaaS partner-key
> 路徑不受影響。