diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index 927c968..eedb181 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -73,6 +73,10 @@ const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun'; * SUBMISSIONS_KV:registry worker 用(component 投稿)。漏建會讓 registry deploy 失敗 → * 壓測 §2.6/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它, * 殘留官方舊 id → wrangler deploy 因 KV 不存在而失敗)。補進來後回到 21/21。 + * OAUTH_KV:arcrun-mcp worker 用(OAuth 2.1 server 的短效 authorization code + access token, + * 帶 TTL、key 用 SHA-256 hash)。mcp/wrangler.toml 綁 OAUTH_KV,占位 id 由 injectWranglerConfig + * 換成用戶帳號的真 id(比照上面同一套 title→binding 注入)。漏建 → mcp deploy 失敗(同 SUBMISSIONS_KV + * 家族)。見 mcp/OAUTH.md §4/§7。 */ export const REQUIRED_KV_NAMESPACES = [ 'WEBHOOKS', @@ -83,6 +87,7 @@ export const REQUIRED_KV_NAMESPACES = [ 'ANALYTICS_KV', 'EXEC_CONTEXT', 'SUBMISSIONS_KV', + 'OAUTH_KV', ] as const; /** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/ diff --git a/mcp/OAUTH.md b/mcp/OAUTH.md new file mode 100644 index 0000000..de15f98 --- /dev/null +++ b/mcp/OAUTH.md @@ -0,0 +1,159 @@ +# arcrun-mcp OAuth 2.1 Server(claude.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 ` 的 `` 直接當成 `org_namespace` 明碼放行。於是**任何知道 URL 的人送 `Bearer leo` +就能讀寫 leo 的全部資料**。這正是本次要關掉的洞。 + +**鐵律**:只知道「網址 + 明碼 namespace」的人,必須讀不到任何資料。 + +## 2. 安全模型(owner secret 怎麼把關) + +claude.ai remote connector 走 **OAuth 2.1 + PKCE(S256)**。整條鏈唯一的「人類祕密閘」在 +`/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 一次性 + 極短 TTL(600s)**:讀到即從 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**:code/token 不以明碼當 key,KV list 也拿不到可用憑證(比照 + `wasi-shim.ts` 只寫短效 oauth2 cache 的精神)。 +- **owner secret/static 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 Metadata:authorize/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="/.well-known/oauth-protected-resource"`**(RFC 9728 §5.1),claude.ai 靠這個 +發現 OAuth。 + +metadata 以「當前請求 origin」動態生成 → 同一份碼在 `mcp.arcrun.dev` 與 `arcrun-mcp..workers.dev` +都正確。 + +## 4. 各資料存哪(儲存鐵律逐項) + +| 資料 | 存哪 | 理由 | +|---|---|---| +| **authorization code** | 短效 KV `OAUTH_KV`,key=`oauth:code:`,TTL 600s,一次性 | 「取得的暫時性認證」,允許進短效 KV | +| **access token** | 短效 KV `OAUTH_KV`,key=`oauth:tok:`,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 + 到期重走 OAuth(owner 重輸一次 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 store:authorization code 一次性(consume 後失效,防重放)、access token 存取 + `exp` 過期、key 為 hash。 +- metadata:origin 推導、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-key(mock 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 deploy,mistakes #23 codeload 陷阱下走的路徑)→ 需手動**: + `wrangler kv namespace create OAUTH_MCP` → 把 id 貼進 `mcp/wrangler.toml` 的 `[[kv_namespaces]] OAUTH_KV`。 +2. **設 owner secret(CF 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 /.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 +> 路徑不受影響。 diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 983a2c5..4a5974c 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -5,8 +5,15 @@ import { partnerAuthMiddleware } from "./middleware/partner-auth.js"; import { handleMcpRequest } from "./mcp-handler.js"; import { inspectorHtml } from "./pages/inspector.js"; import { kbdbFetch } from "./lib/kbdb-client.js"; +import { registerOAuthRoutes } from "./oauth/routes.js"; const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>(); + +// ── OAuth 2.1 server 路由(掛在 worker 根路徑,非 /mcp)────────────────────────── +// well-known / authorize / token / register 必須在 origin 根,claude.ai 遠端 connector 才發現得到。 +// 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。 +registerOAuthRoutes(_app); + const app = _app.basePath('/mcp'); app.use("*", cors({ @@ -239,4 +246,6 @@ app.post("/", partnerAuthMiddleware, async (c) => { return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken); }); -export default app; +// 輸出根 app(_app):與 basePath('/mcp') 的 app 共享同一份 router,故 OAuth 根路由與 +// /mcp 路由都能被分派。(若輸出 app 則根路徑的 well-known 分派行為依賴 basePath 細節,改輸出 _app 明確。) +export default _app; diff --git a/mcp/src/middleware/partner-auth.ts b/mcp/src/middleware/partner-auth.ts index 03201c8..0206112 100644 --- a/mcp/src/middleware/partner-auth.ts +++ b/mcp/src/middleware/partner-auth.ts @@ -1,48 +1,98 @@ import { Context, Next } from "hono"; import { Env } from "../types.js"; +import { getAccessToken } from "../oauth/store.js"; +import { constantTimeEqual } from "../oauth/crypto.js"; +import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js"; +/** + * MCP / GUI 端點認證中介層。 + * + * 安全定調(leo 鐵律):**只知道「網址 + 明碼 namespace」的人,必須讀不到任何資料。** + * 認證順序(先到先得): + * 1. OAuth 2.1 access_token(OAUTH_KV 查得到)→ 解出綁定 namespace。遠端 claude.ai 走這條。 + * 2. MCP_STATIC_TOKEN(CF Secret,真祕密)→ 解成 MCP_OWNER_NAMESPACE。本機 CLI/GUI/Claude Code 相容用。 + * 3. 官方 SaaS(MULTI_TENANT 未設 / "true")→ KBDB partner-key 驗證(行為不變)。 + * 4. 【預設關】ALLOW_PLAINTEXT_NAMESPACE="true" → 恢復舊「Bearer 明碼即 namespace」(=已修掉的漏洞,僅遷移期)。 + * 5. 皆不符 → 401 + WWW-Authenticate(RFC 9728),讓 claude.ai 發現 OAuth。 + * + * ⚠️ 舊行為(MULTI_TENANT=false 時把 Bearer 明碼直接當 org_namespace)是本次要修掉的漏洞, + * 已從預設路徑移除;只在明確設 ALLOW_PLAINTEXT_NAMESPACE="true" 的遷移情境才恢復。 + */ export async function partnerAuthMiddleware( c: Context<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>, next: Next ) { - const authHeader = c.req.header('Authorization'); - if (!authHeader?.startsWith('Bearer ')) { - return c.json({ error: 'Missing or invalid Authorization header' }, 401); + const origin = originOf(c.req.url); + const unauthorized = (desc: string, error?: string) => + c.json({ error: "unauthorized", error_description: desc }, 401, { + "WWW-Authenticate": wwwAuthenticateHeader(origin, error), + }); + + const authHeader = c.req.header("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return unauthorized("Missing or invalid Authorization header"); + } + const token = authHeader.slice(7); + if (!token) { + return unauthorized("Empty bearer token"); } - const token = authHeader.slice(7); + // 1) OAuth 2.1 access token(遠端 connector 的正規路徑)。 + if (c.env.OAUTH_KV) { + const at = await getAccessToken(c.env.OAUTH_KV, token); + if (at) { + // RFC 8707 audience 驗證:token 只能用在它被簽發的目標 MCP server。 + // token 的 aud 是簽發時綁定的 resource;須等於「本次請求 origin 算出的 canonical resource URI」, + // 否則拒絕(防別的 arcrun-mcp 部署簽的 token 拿來這裡用 = token passthrough)。 + const expectedAud = resourceUri(origin); + if (at.aud !== expectedAud) { + return unauthorized("Access token audience mismatch", "invalid_token"); + } + c.set("org_namespace", at.namespace); + c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份) + await next(); + return; + } + } - // Self-hosted 單租戶(MULTI_TENANT=false):Bearer 帶的是 namespace 明碼,不是平台 partner key。 - // 與 cypher-executor 一致——cypher 把 X-Arcrun-API-Key 當「不驗證的 opaque 分區 key」(namespace - // 是明碼分區標籤非密碼,mindset §3 arcrun 不做授權判斷)。故 self-hosted 模式不打 KBDB partner - // 驗證,直接把 token 當 org_namespace。SDD: mcp-account-source.md;HANDOFF §3b。 - if (c.env.MULTI_TENANT === 'false') { - c.set('org_namespace', token); - c.set('partner_token', token); // 下游轉發給 cypher 當 X-Arcrun-API-Key(與 CLI 同一份身份) + // 2) 本機相容:真祕密 static token(CF Secret)→ owner namespace。取代舊明碼路徑。 + if (c.env.MCP_STATIC_TOKEN && constantTimeEqual(token, c.env.MCP_STATIC_TOKEN)) { + const ns = c.env.MCP_OWNER_NAMESPACE || "leo"; + c.set("org_namespace", ns); + c.set("partner_token", ns); await next(); return; } - // 官方 SaaS(MULTI_TENANT 未設 / "true"):維持 partner-key 驗證(行為不變)。 - const resp = await c.env.KBDB.fetch( - `http://kbdb/partners/${encodeURIComponent(token)}/info`, - { - headers: { - 'Authorization': `Bearer ${c.env.KBDB_INTERNAL_TOKEN}` - } + // 3) 官方 SaaS:KBDB partner-key 驗證(行為完全不變)。 + if (c.env.MULTI_TENANT !== "false") { + const resp = await c.env.KBDB.fetch( + `http://kbdb/partners/${encodeURIComponent(token)}/info`, + { headers: { Authorization: `Bearer ${c.env.KBDB_INTERNAL_TOKEN}` } } + ); + if (!resp.ok) { + return unauthorized("Invalid or expired partner key", "invalid_token"); } - ); - - if (!resp.ok) { - return c.json({ error: 'Invalid or expired partner key' }, 401); + const info = await resp.json<{ valid: boolean; org_namespace: string }>(); + if (!info.valid) { + return unauthorized("Invalid or expired partner key", "invalid_token"); + } + c.set("org_namespace", info.org_namespace); + c.set("partner_token", token); + await next(); + return; } - const info = await resp.json<{ valid: boolean; org_namespace: string }>(); - if (!info.valid) { - return c.json({ error: 'Invalid or expired partner key' }, 401); + // 4)【預設關,不安全,SUNSET】遷移逃生門:恢復舊明碼 namespace 行為。 + // ⚠️ 這是暫時相容路徑,遷移驗收完即刪整段(含 Env.ALLOW_PLAINTEXT_NAMESPACE 欄位)。 + // 追蹤 issue:見 OAUTH.md §5 / wrangler.toml 註解。留著只為遷移期,別當長期選項。 + if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") { + c.set("org_namespace", token); + c.set("partner_token", token); + await next(); + return; } - c.set('org_namespace', info.org_namespace); - c.set('partner_token', token); // 給下游(cypher-executor / KBDB)轉發用 - await next(); + // 5) self-hosted 但沒帶 OAuth token / static token → 拒絕(明碼 namespace 不再放行)。 + return unauthorized("Bearer token not recognized; complete OAuth to obtain an access token", "invalid_token"); } diff --git a/mcp/src/oauth/consent.ts b/mcp/src/oauth/consent.ts new file mode 100644 index 0000000..ef1bd2a --- /dev/null +++ b/mcp/src/oauth/consent.ts @@ -0,0 +1,86 @@ +// /authorize 同意頁:極簡單檔 HTML,要求輸入 owner 祕密才發碼。零外部資源。 +// 所有反射進 HTML 的 OAuth 參數都 escape,防 XSS(redirect_uri / state / client_id 由外部帶入)。 + +/** HTML attribute / text 跳脫。 */ +export function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** 同意頁需要 round-trip 回 POST /authorize 的隱藏欄位。 */ +export interface ConsentParams { + client_id: string; + redirect_uri: string; + state: string; + code_challenge: string; + code_challenge_method: string; + scope: string; + resource: string; +} + +function hidden(name: string, value: string): string { + return ``; +} + +/** + * 同意頁 HTML。`error` 有值時(如祕密錯誤)顯示紅字,但仍保留隱藏欄位讓 owner 重試。 + */ +export function consentPage(p: ConsentParams, error?: string): string { + const fields = [ + hidden("client_id", p.client_id), + hidden("redirect_uri", p.redirect_uri), + hidden("state", p.state), + hidden("code_challenge", p.code_challenge), + hidden("code_challenge_method", p.code_challenge_method), + hidden("scope", p.scope), + hidden("resource", p.resource), + ].join("\n "); + + const errBlock = error + ? `` + : ""; + + return ` + + + + +Arcrun MCP 授權 + + + +

Arcrun MCP 授權

+

應用程式 ${esc(p.client_id)} 想連上你的 Arcrun MCP, + 這會讓它能讀寫你的 KBDB 全部資料

+ ${errBlock} +
+ ${fields} + + + +
+

祕密不正確不會發出授權碼。此頁不儲存你的輸入。

+ +`; +} diff --git a/mcp/src/oauth/crypto.ts b/mcp/src/oauth/crypto.ts new file mode 100644 index 0000000..20f6381 --- /dev/null +++ b/mcp/src/oauth/crypto.ts @@ -0,0 +1,61 @@ +// OAuth 加密工具:PKCE S256 驗證、SHA-256 hash、隨機不可猜 token、常數時間比對。 +// 全走 Web Crypto(Workers / Node 18+ 全域 crypto.subtle),無外部依賴。 + +/** base64url 編碼(無 padding,RFC 7636 §A)。 */ +function base64UrlEncode(bytes: Uint8Array): string { + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** 產生密碼學等級隨機 token(bytes 位元組 → base64url 字串,預設 32 bytes = 256-bit)。 */ +export function randomToken(bytes = 32): string { + const buf = new Uint8Array(bytes); + crypto.getRandomValues(buf); + return base64UrlEncode(buf); +} + +/** SHA-256 → base64url(PKCE code_challenge 用;RFC 7636 §4.2 BASE64URL(SHA256(verifier)))。 */ +export async function sha256Base64Url(input: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return base64UrlEncode(new Uint8Array(digest)); +} + +/** SHA-256 → hex(拿來當 KV key:不把可用的 raw token 直接當 key,KV list 也看不到明碼)。 */ +export async function sha256Hex(input: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * 常數時間字串比對(防 timing attack;比 owner secret / static token 用)。 + * 長度不同直接 false,但仍走完固定迴圈避免長度洩漏。 + */ +export function constantTimeEqual(a: string, b: string): boolean { + const ab = new TextEncoder().encode(a); + const bb = new TextEncoder().encode(b); + let diff = ab.length ^ bb.length; + const len = Math.max(ab.length, bb.length); + for (let i = 0; i < len; i++) { + diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0); + } + return diff === 0; +} + +/** + * 驗證 PKCE:BASE64URL(SHA256(code_verifier)) === code_challenge(僅支援 S256)。 + * method 非 "S256"(含缺省的 "plain")一律拒絕——OAuth 2.1 + 本 server 只宣告 S256。 + */ +export async function verifyPkceS256( + codeVerifier: string, + codeChallenge: string, + method: string | undefined, +): Promise { + if (method !== "S256") return false; + if (!codeVerifier || !codeChallenge) return false; + // RFC 7636 §4.1:verifier 43–128 字元、[A-Za-z0-9-._~]。 + if (codeVerifier.length < 43 || codeVerifier.length > 128) return false; + if (!/^[A-Za-z0-9\-._~]+$/.test(codeVerifier)) return false; + const computed = await sha256Base64Url(codeVerifier); + return constantTimeEqual(computed, codeChallenge); +} diff --git a/mcp/src/oauth/metadata.ts b/mcp/src/oauth/metadata.ts new file mode 100644 index 0000000..e61e488 --- /dev/null +++ b/mcp/src/oauth/metadata.ts @@ -0,0 +1,81 @@ +// OAuth 探索文件(RFC 9728 Protected Resource Metadata、RFC 8414 Authorization Server Metadata) +// 以「當前請求的 origin」動態組出——同一份碼在 mcp.arcrun.dev 與 arcrun-mcp..workers.dev 都正確。 + +/** 從請求 URL 取 origin(scheme://host),canonical 用小寫 scheme/host。 */ +export function originOf(reqUrl: string): string { + const u = new URL(reqUrl); + return `${u.protocol.toLowerCase()}//${u.host.toLowerCase()}`; +} + +/** 本 MCP server 的 canonical resource URI(RFC 8707 audience)——MCP 端點在 /mcp。 */ +export function resourceUri(origin: string): string { + return `${origin}/mcp`; +} + +/** + * 把 client 傳的 `resource`(RFC 8707)正規化成 canonical 形式,好和 `resourceUri(origin)` 嚴格比對。 + * 正規化規則與 originOf/resourceUri 產出的 canonical 一致: + * - scheme + host 小寫(URL 也自動去掉預設 port,如 https 的 :443) + * - path 去尾斜線(`/mcp/` → `/mcp`;根 `/` 保留) + * - 丟棄 query / fragment(resource 不該帶) + * 非法 URL → null。 + */ +export function normalizeResource(value: string): string | null { + let u: URL; + try { + u = new URL(value); + } catch { + return null; + } + const scheme = u.protocol.toLowerCase(); // 含結尾冒號,如 "https:" + const host = u.host.toLowerCase(); // 含非預設 port;預設 port 已被 URL 去掉 + let path = u.pathname; + if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1); + return `${scheme}//${host}${path}`; +} + +/** + * 判斷 client 傳的 `resource` 正規化後是否 == 本 server 的 canonical resource URI。 + * 用於 /authorize、/token 的簽發端把關(fail fast),避免尾斜線/大小寫變體造成 + * 「OAuth 成功但每次打 /mcp 都 401 aud mismatch」的最難 debug 劇本。 + */ +export function resourceMatches(resourceParam: string, origin: string): boolean { + const n = normalizeResource(resourceParam); + return n !== null && n === resourceUri(origin); +} + +/** RFC 9728 Protected Resource Metadata。 */ +export function protectedResourceMetadata(origin: string) { + return { + resource: resourceUri(origin), + authorization_servers: [origin], + scopes_supported: ["mcp"], + bearer_methods_supported: ["header"], + }; +} + +/** RFC 8414 Authorization Server Metadata(本 worker 同時是 AS)。 */ +export function authorizationServerMetadata(origin: string) { + return { + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + registration_endpoint: `${origin}/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], // public client + PKCE + scopes_supported: ["mcp"], + }; +} + +/** + * RFC 9728 §5.1 WWW-Authenticate 回應標頭——401 時指向 protected-resource metadata, + * claude.ai 靠這個發現 OAuth authorization server。 + */ +export function wwwAuthenticateHeader(origin: string, error?: string): string { + const metaUrl = `${origin}/.well-known/oauth-protected-resource`; + let h = `Bearer resource_metadata="${metaUrl}"`; + if (error) h += `, error="${error}"`; + return h; +} diff --git a/mcp/src/oauth/routes.ts b/mcp/src/oauth/routes.ts new file mode 100644 index 0000000..2dea19c --- /dev/null +++ b/mcp/src/oauth/routes.ts @@ -0,0 +1,325 @@ +// OAuth 2.1 + PKCE server 路由。掛在 worker 根路徑(非 /mcp basePath), +// 因為 well-known / authorize / token / register 都得在 origin 根,claude.ai 才發現得到。 +// 安全模型與相容決策見 mcp/OAUTH.md。 +import { Hono } from "hono"; +import type { Env as HonoBaseEnv } from "hono"; +import { Env } from "../types.js"; +import { + randomToken, + verifyPkceS256, + constantTimeEqual, +} from "./crypto.js"; +import { + putAuthCode, + consumeAuthCode, + putAccessToken, + AUTH_CODE_TTL_SECONDS, +} from "./store.js"; +import { + originOf, + resourceUri, + resourceMatches, + protectedResourceMetadata, + authorizationServerMetadata, +} from "./metadata.js"; +import { consentPage, ConsentParams } from "./consent.js"; + +const DEFAULT_TOKEN_TTL = 2592000; // 30 天 +const DEFAULT_REDIRECT_HOSTS = ["claude.ai", "claude.com", "anthropic.com"]; + +const CORS_JSON = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Cache-Control": "no-store", +} as const; + +function ownerNamespace(env: Env): string { + return env.MCP_OWNER_NAMESPACE || "leo"; +} + +function tokenTtl(env: Env): number { + const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL; +} + +/** redirect_uri 白名單檢查(OAuth 2.1:http 只准 localhost,其餘須 https 且 host 在白名單)。 */ +function isAllowedRedirect(uri: string, env: Env): boolean { + let u: URL; + try { + u = new URL(uri); + } catch { + return false; + } + const host = u.hostname.toLowerCase(); + const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1"; + if (u.protocol === "http:") return isLocal; // http 僅限本機 + if (u.protocol !== "https:") return false; + if (isLocal) return true; + const configured = (env.MCP_ALLOWED_REDIRECT_HOSTS ?? "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + const list = configured.length ? configured : DEFAULT_REDIRECT_HOSTS; + return list.some((h) => host === h || host.endsWith("." + h)); +} + +/** 從 form 或 JSON body 讀參數(token/register 端點的 content-type 兩種都容忍)。 */ +async function readParams(req: Request): Promise> { + const ct = req.headers.get("content-type") ?? ""; + try { + if (ct.includes("application/json")) { + const j = (await req.json()) as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(j)) if (typeof v === "string") out[k] = v; + return out; + } + const form = await req.formData(); + const out: Record = {}; + for (const [k, v] of form.entries()) if (typeof v === "string") out[k] = v; + return out; + } catch { + return {}; + } +} + +/** 組 redirect 回 client 的 URL(把 query 併進 redirect_uri)。 */ +function redirectWith(redirectUri: string, params: Record): string { + const u = new URL(redirectUri); + for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v); + return u.toString(); +} + +/** + * 掛載 OAuth 路由到根 app。呼叫端須把「MCP 端點以外」的根路徑交給此 app。 + * 泛型接受任何帶 `Bindings: Env` 的 Hono 實例(含額外 Variables),故可直接掛在主 app 上。 + */ +export function registerOAuthRoutes< + E extends HonoBaseEnv & { Bindings: Env }, +>(app: Hono): void { + // ── CORS 預檢 ─────────────────────────────────────────────────────────────── + app.options("/register", (c) => c.body(null, 204, CORS_JSON)); + app.options("/token", (c) => c.body(null, 204, CORS_JSON)); + app.options("/.well-known/oauth-protected-resource", (c) => c.body(null, 204, CORS_JSON)); + app.options("/.well-known/oauth-authorization-server", (c) => c.body(null, 204, CORS_JSON)); + + // ── RFC 9728 Protected Resource Metadata(根 + /mcp 路徑後綴變體)──────────────── + const prm = (c: { req: { url: string } }) => + protectedResourceMetadata(originOf(c.req.url)); + app.get("/.well-known/oauth-protected-resource", (c) => + c.json(prm(c), 200, CORS_JSON)); + app.get("/.well-known/oauth-protected-resource/mcp", (c) => + c.json(prm(c), 200, CORS_JSON)); + + // ── RFC 8414 Authorization Server Metadata(根 + /mcp 後綴變體)────────────────── + const asm = (c: { req: { url: string } }) => + authorizationServerMetadata(originOf(c.req.url)); + app.get("/.well-known/oauth-authorization-server", (c) => + c.json(asm(c), 200, CORS_JSON)); + app.get("/.well-known/oauth-authorization-server/mcp", (c) => + c.json(asm(c), 200, CORS_JSON)); + + // ── RFC 7591 Dynamic Client Registration ─────────────────────────────────────── + // public client + PKCE → 不發 client_secret;且刻意「無狀態」不落地 client(見 OAUTH.md 相容決策)。 + app.post("/register", async (c) => { + // DCR 一律 JSON(RFC 7591)。單次解析取 redirect_uris(陣列)+ client_name。 + let raw: { redirect_uris?: unknown; client_name?: unknown } = {}; + try { + raw = (await c.req.raw.json()) as typeof raw; + } catch { + /* 無 / 非 JSON body:容忍,redirect_uris 視為空 */ + } + const redirectUris: string[] = Array.isArray(raw.redirect_uris) + ? raw.redirect_uris.filter((x): x is string => typeof x === "string") + : []; + const clientName = typeof raw.client_name === "string" ? raw.client_name : "MCP Client"; + for (const uri of redirectUris) { + if (!isAllowedRedirect(uri, c.env)) { + return c.json( + { error: "invalid_redirect_uri", error_description: `redirect_uri not allowed: ${uri}` }, + 400, + CORS_JSON, + ); + } + } + const clientId = "mcp_" + randomToken(16); + return c.json( + { + client_id: clientId, + client_id_issued_at: Math.floor(Date.now() / 1000), + redirect_uris: redirectUris, + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + client_name: clientName, + }, + 201, + CORS_JSON, + ); + }); + + // ── GET /authorize:呈現 owner 祕密同意頁 ─────────────────────────────────────── + app.get("/authorize", (c) => { + const q = c.req.query(); + if (q.response_type !== "code") { + return c.text("unsupported_response_type: only 'code' is supported", 400); + } + if (q.code_challenge_method !== "S256" || !q.code_challenge) { + return c.text("invalid_request: PKCE S256 code_challenge required", 400); + } + if (!q.redirect_uri || !isAllowedRedirect(q.redirect_uri, c.env)) { + // redirect_uri 本身可疑 → 絕不 redirect(防 open redirect),直接顯示錯誤。 + return c.text("invalid_request: redirect_uri missing or not allowed", 400); + } + const canonicalResource = resourceUri(originOf(c.req.url)); + // RFC 8707:client 傳了 resource 就必須正規化後 == 本 server canonical,否則簽發端 fail fast。 + // redirect_uri 已驗過 → 用 OAuth 錯誤 redirect 帶 error=invalid_target(比顯示 400 更符規範)。 + if (q.resource && !resourceMatches(q.resource, originOf(c.req.url))) { + return c.redirect( + redirectWith(q.redirect_uri, { + error: "invalid_target", + error_description: "resource does not match this MCP server", + ...(q.state ? { state: q.state } : {}), + }), + 302, + ); + } + if (!c.env.MCP_OWNER_SECRET) { + return c.text("server_error: MCP_OWNER_SECRET not configured", 503); + } + const params: ConsentParams = { + client_id: q.client_id ?? "", + redirect_uri: q.redirect_uri, + state: q.state ?? "", + code_challenge: q.code_challenge, + code_challenge_method: "S256", + scope: q.scope ?? "mcp", + resource: canonicalResource, // 一律存 canonical,不存 client 原樣值 + }; + return c.html(consentPage(params)); + }); + + // ── POST /authorize:驗 owner 祕密 → 發 authorization code → redirect ─────────── + app.post("/authorize", async (c) => { + const p = await readParams(c.req.raw); + const redirectUri = p.redirect_uri ?? ""; + // 再驗一次 redirect_uri(POST 的欄位是隱藏帶回來的,仍須擋竄改)。 + if (!redirectUri || !isAllowedRedirect(redirectUri, c.env)) { + return c.text("invalid_request: redirect_uri not allowed", 400); + } + if (p.code_challenge_method !== "S256" || !p.code_challenge) { + return c.text("invalid_request: PKCE S256 required", 400); + } + const canonicalResource = resourceUri(originOf(c.req.url)); + // RFC 8707:resource(隱藏欄位帶回,仍可能被竄改)→ 正規化後須 == canonical,否則 redirect 帶 invalid_target。 + if (p.resource && !resourceMatches(p.resource, originOf(c.req.url))) { + return c.redirect( + redirectWith(redirectUri, { + error: "invalid_target", + error_description: "resource does not match this MCP server", + ...(p.state ? { state: p.state } : {}), + }), + 302, + ); + } + if (!c.env.MCP_OWNER_SECRET) { + return c.text("server_error: MCP_OWNER_SECRET not configured", 503); + } + const consent: ConsentParams = { + client_id: p.client_id ?? "", + redirect_uri: redirectUri, + state: p.state ?? "", + code_challenge: p.code_challenge, + code_challenge_method: "S256", + scope: p.scope ?? "mcp", + resource: canonicalResource, // 一律存 canonical,不存 client 原樣值 + }; + // ★ owner 祕密把關:錯誤不發碼、重顯同意頁。這是「只知 URL 的人進不來」的唯一閘。 + const supplied = p.owner_secret ?? ""; + if (!supplied || !constantTimeEqual(supplied, c.env.MCP_OWNER_SECRET)) { + return c.html(consentPage(consent, "Owner 祕密不正確,請重試。"), 401); + } + if (!c.env.OAUTH_KV) { + return c.text("server_error: OAUTH_KV not configured", 503); + } + const code = randomToken(32); + await putAuthCode(c.env.OAUTH_KV, code, { + client_id: consent.client_id, + redirect_uri: redirectUri, + code_challenge: consent.code_challenge, + code_challenge_method: "S256", + scope: consent.scope, + resource: consent.resource, + namespace: ownerNamespace(c.env), + }); + const location = redirectWith(redirectUri, { + code, + ...(consent.state ? { state: consent.state } : {}), + }); + return c.redirect(location, 302); + }); + + // ── POST /token:code + PKCE verifier → access_token ──────────────────────────── + app.post("/token", async (c) => { + const p = await readParams(c.req.raw); + const err = (code: string, desc: string, status = 400) => + c.json({ error: code, error_description: desc }, status as 400, CORS_JSON); + + if (p.grant_type !== "authorization_code") { + return err("unsupported_grant_type", "only authorization_code is supported"); + } + if (!p.code || !p.code_verifier || !p.redirect_uri) { + return err("invalid_request", "code, code_verifier and redirect_uri are required"); + } + // RFC 8707:token request 帶 resource 就得正規化後 == canonical,否則簽發端拒(400 invalid_target)。 + if (p.resource && !resourceMatches(p.resource, originOf(c.req.url))) { + return err("invalid_target", "resource does not match this MCP server"); + } + if (!c.env.OAUTH_KV) { + return err("server_error", "OAUTH_KV not configured", 503); + } + const data = await consumeAuthCode(c.env.OAUTH_KV, p.code); // 一次性 + if (!data) { + return err("invalid_grant", "authorization code invalid or expired"); + } + if (data.redirect_uri !== p.redirect_uri) { + return err("invalid_grant", "redirect_uri mismatch"); + } + if (p.client_id && data.client_id && p.client_id !== data.client_id) { + return err("invalid_grant", "client_id mismatch"); + } + const pkceOk = await verifyPkceS256(p.code_verifier, data.code_challenge, data.code_challenge_method); + if (!pkceOk) { + return err("invalid_grant", "PKCE verification failed"); + } + + const ttl = tokenTtl(c.env); + const accessToken = randomToken(32); + await putAccessToken( + c.env.OAUTH_KV, + accessToken, + { + namespace: data.namespace, + client_id: data.client_id, + scope: data.scope, + // RFC 8707:aud 一律用「本 server canonical resource URI」(非 client 原樣值)。 + // authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。 + aud: resourceUri(originOf(c.req.url)), + exp: Math.floor(Date.now() / 1000) + ttl, + }, + ttl, + ); + return c.json( + { + access_token: accessToken, + token_type: "Bearer", + expires_in: ttl, + scope: data.scope, + }, + 200, + CORS_JSON, + ); + }); +} + +export { AUTH_CODE_TTL_SECONDS }; diff --git a/mcp/src/oauth/store.ts b/mcp/src/oauth/store.ts new file mode 100644 index 0000000..4732db5 --- /dev/null +++ b/mcp/src/oauth/store.ts @@ -0,0 +1,78 @@ +// OAuth 短效認證儲存(KV)。儲存鐵律(leo + wiki): +// 只有「取得的暫時性認證」可進 KV,且帶 TTL——authorization code、access token 是也。 +// 長效機密(owner secret)走 CF Secrets,不進此處。DCR client 不落地(見 OAUTH.md 相容決策)。 +// KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。 +import { sha256Hex } from "./crypto.js"; + +/** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */ +export interface AuthCodeData { + client_id: string; + redirect_uri: string; + code_challenge: string; + code_challenge_method: string; + scope: string; + /** RFC 8707 resource:綁定 token 的目標 MCP server(audience)。 */ + resource: string; + /** 換發後 token 綁定的資料分區(owner namespace)。 */ + namespace: string; +} + +/** access token 綁定的資料。 */ +export interface AccessTokenData { + namespace: string; + client_id: string; + scope: string; + /** token 的目標受眾(= 本 MCP server 的 canonical URI),驗證時比對。 */ + aud: string; + /** 過期時間(epoch 秒),與 KV TTL 雙保險。 */ + exp: number; +} + +const CODE_PREFIX = "oauth:code:"; +const TOKEN_PREFIX = "oauth:tok:"; +/** authorization code 存活秒數(一次性、極短效)。 */ +export const AUTH_CODE_TTL_SECONDS = 600; + +/** 存 authorization code(TTL 極短)。回傳 raw code 給 client。 */ +export async function putAuthCode(kv: KVNamespace, code: string, data: AuthCodeData): Promise { + const key = CODE_PREFIX + (await sha256Hex(code)); + await kv.put(key, JSON.stringify(data), { expirationTtl: AUTH_CODE_TTL_SECONDS }); +} + +/** 取出並「消費」authorization code(一次性:讀到即刪,防重放)。找不到回 null。 */ +export async function consumeAuthCode(kv: KVNamespace, code: string): Promise { + const key = CODE_PREFIX + (await sha256Hex(code)); + const raw = await kv.get(key); + if (!raw) return null; + await kv.delete(key); // 一次性使用(OAuth 2.1:code 用過必失效) + try { + return JSON.parse(raw) as AuthCodeData; + } catch { + return null; + } +} + +/** 存 access token(TTL = ttlSeconds)。回傳 raw token 給 client。 */ +export async function putAccessToken( + kv: KVNamespace, + token: string, + data: AccessTokenData, + ttlSeconds: number, +): Promise { + const key = TOKEN_PREFIX + (await sha256Hex(token)); + await kv.put(key, JSON.stringify(data), { expirationTtl: ttlSeconds }); +} + +/** 查 access token → 綁定資料。找不到 / 過期回 null(KV TTL 到期會自動消失,另做 exp 雙檢)。 */ +export async function getAccessToken(kv: KVNamespace, token: string): Promise { + const key = TOKEN_PREFIX + (await sha256Hex(token)); + const raw = await kv.get(key); + if (!raw) return null; + try { + const data = JSON.parse(raw) as AccessTokenData; + if (typeof data.exp === "number" && data.exp * 1000 < Date.now()) return null; + return data; + } catch { + return null; + } +} diff --git a/mcp/src/types.ts b/mcp/src/types.ts index a36a14f..bd27223 100644 --- a/mcp/src/types.ts +++ b/mcp/src/types.ts @@ -15,6 +15,29 @@ export interface Env { // 未設 / "true" = 官方 SaaS:維持 partner-key 驗證(行為完全不變)。 // SDD: sdk-and-website/mcp-account-source.md;HANDOFF §3b。 MULTI_TENANT?: string; + + // ── OAuth 2.1 server(claude.ai 遠端 connector 安全登入,見 mcp/OAUTH.md)────────── + // 短效認證儲存:authorization code(TTL ~600s)+ access token(TTL = MCP_TOKEN_TTL)。 + // 只放「取得的暫時性認證」,key 用 SHA-256 hash(KV list 不外洩可用 token)。長效機密不進 KV。 + OAUTH_KV?: KVNamespace; + // Owner 祕密(CF Secret,非 KV、非明碼 var):/authorize 同意頁的把關密碼。 + // 只有 owner 知道 → 「只知 URL + 明碼 namespace」的人走不完 OAuth,拿不到 token。 + // 未設 → OAuth /authorize 回 503(拒絕在無把關下發碼,不留不安全預設)。 + MCP_OWNER_SECRET?: string; + // OAuth 換發出的 access_token 綁定的 namespace(owner 的資料分區)。預設 "leo"。 + MCP_OWNER_NAMESPACE?: string; + // access_token 存活秒數(同時是 KV TTL)。字串(toml var)。預設 2592000(30 天)。 + // 過期後 claude.ai 重走 OAuth(owner 重輸祕密)——刻意不做 refresh token 以免長效機密落地。 + MCP_TOKEN_TTL?: string; + // 本機 CLI / GUI / 本機 Claude Code 的相容用「真祕密 token」(CF Secret)。 + // 設了才啟用:Bearer 精確等於此值 → 解成 MCP_OWNER_NAMESPACE。取代舊「明碼 namespace 當 bearer」漏洞路徑。 + MCP_STATIC_TOKEN?: string; + // 【不安全】相容逃生門:="true" 時恢復舊「Bearer 明碼即 namespace」行為(self-hosted)。 + // 預設 undefined = 關(安全)。僅供遷移期,設了等於重新打開被修掉的漏洞——別在正式環境開。 + ALLOW_PLAINTEXT_NAMESPACE?: string; + // 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。 + // 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。 + MCP_ALLOWED_REDIRECT_HOSTS?: string; } export interface ToolContext { diff --git a/mcp/tests/unit/oauth.test.ts b/mcp/tests/unit/oauth.test.ts new file mode 100644 index 0000000..c8d0c3f --- /dev/null +++ b/mcp/tests/unit/oauth.test.ts @@ -0,0 +1,620 @@ +import { describe, it, expect } from "vitest"; +import { Hono } from "hono"; +import { + randomToken, + sha256Base64Url, + sha256Hex, + constantTimeEqual, + verifyPkceS256, +} from "../../src/oauth/crypto.js"; +import { + putAuthCode, + consumeAuthCode, + putAccessToken, + getAccessToken, +} from "../../src/oauth/store.js"; +import { + originOf, + resourceUri, + normalizeResource, + resourceMatches, + protectedResourceMetadata, + authorizationServerMetadata, + wwwAuthenticateHeader, +} from "../../src/oauth/metadata.js"; +import { esc, consentPage } from "../../src/oauth/consent.js"; +import { registerOAuthRoutes } from "../../src/oauth/routes.js"; +import type { Env } from "../../src/types.js"; + +// ── 記憶體 KV mock(支援 expirationTtl → 用 exp 過期;delete)───────────────────── +function makeKV(): KVNamespace { + const map = new Map(); + const kv = { + async put(key: string, value: string, opts?: { expirationTtl?: number }) { + map.set(key, { + value, + exp: opts?.expirationTtl ? Date.now() + opts.expirationTtl * 1000 : undefined, + }); + }, + async get(key: string) { + const e = map.get(key); + if (!e) return null; + if (e.exp && e.exp < Date.now()) { + map.delete(key); + return null; + } + return e.value; + }, + async delete(key: string) { + map.delete(key); + }, + }; + return kv as unknown as KVNamespace; +} + +// PKCE:verifier "1234...43+ chars" → 直接算 challenge。 +async function pkcePair() { + const verifier = "a".repeat(64); + const challenge = await sha256Base64Url(verifier); + return { verifier, challenge }; +} + +function baseEnv(over: Partial = {}): Env { + return { + COMPONENT_REGISTRY: {} as Fetcher, + CYPHER_EXECUTOR: {} as Fetcher, + KBDB: {} as Fetcher, + KBDB_INTERNAL_TOKEN: "internal", + OAUTH_KV: makeKV(), + MCP_OWNER_SECRET: "s3cr3t-owner", + MCP_OWNER_NAMESPACE: "leo", + ...over, + } as Env; +} + +// ── crypto ────────────────────────────────────────────────────────────────────── +describe("oauth/crypto", () => { + it("randomToken 長度足夠且每次不同", () => { + const a = randomToken(); + const b = randomToken(); + expect(a).not.toBe(b); + expect(a.length).toBeGreaterThanOrEqual(40); + expect(a).toMatch(/^[A-Za-z0-9\-_]+$/); // base64url 無 padding + }); + + it("sha256Hex/base64url 為已知值", async () => { + // echo -n "abc" | sha256sum → ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + expect(await sha256Hex("abc")).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + // RFC 7636 附錄範例:verifier → challenge + expect(await sha256Base64Url("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")).toBe( + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + ); + }); + + it("constantTimeEqual 正確", () => { + expect(constantTimeEqual("abc", "abc")).toBe(true); + expect(constantTimeEqual("abc", "abd")).toBe(false); + expect(constantTimeEqual("abc", "abcd")).toBe(false); + }); + + it("verifyPkceS256:S256 正確、拒 plain/短 verifier/竄改", async () => { + const { verifier, challenge } = await pkcePair(); + expect(await verifyPkceS256(verifier, challenge, "S256")).toBe(true); + expect(await verifyPkceS256(verifier, challenge, "plain")).toBe(false); + expect(await verifyPkceS256(verifier, challenge, undefined)).toBe(false); + expect(await verifyPkceS256("short", challenge, "S256")).toBe(false); + expect(await verifyPkceS256("b".repeat(64), challenge, "S256")).toBe(false); + }); +}); + +// ── store(短效 KV)──────────────────────────────────────────────────────────── +describe("oauth/store", () => { + it("authorization code 一次性:consume 後即失效(防重放)", async () => { + const kv = makeKV(); + await putAuthCode(kv, "code-1", { + client_id: "c1", + redirect_uri: "https://claude.ai/cb", + code_challenge: "cc", + code_challenge_method: "S256", + scope: "mcp", + resource: "https://mcp/mcp", + namespace: "leo", + }); + const first = await consumeAuthCode(kv, "code-1"); + expect(first?.namespace).toBe("leo"); + const second = await consumeAuthCode(kv, "code-1"); + expect(second).toBeNull(); + }); + + it("access token 存取 + exp 過期回 null", async () => { + const kv = makeKV(); + await putAccessToken( + kv, + "tok-1", + { namespace: "leo", client_id: "c1", scope: "mcp", aud: "https://mcp/mcp", exp: Math.floor(Date.now() / 1000) + 100 }, + 100, + ); + expect((await getAccessToken(kv, "tok-1"))?.namespace).toBe("leo"); + // 已過期 + await putAccessToken( + kv, + "tok-2", + { namespace: "leo", client_id: "c1", scope: "mcp", aud: "x", exp: Math.floor(Date.now() / 1000) - 10 }, + 100, + ); + expect(await getAccessToken(kv, "tok-2")).toBeNull(); + }); + + it("KV key 為 hash(不把 raw token 當 key)", async () => { + // 白盒:store 內部用 sha256Hex,這裡驗 hash 與 raw 不同即可 + expect(await sha256Hex("tok-1")).not.toContain("tok-1"); + }); +}); + +// ── metadata ────────────────────────────────────────────────────────────────── +describe("oauth/metadata", () => { + it("originOf / resourceUri", () => { + expect(originOf("https://Mcp.Arcrun.dev/mcp")).toBe("https://mcp.arcrun.dev"); + expect(resourceUri("https://mcp.arcrun.dev")).toBe("https://mcp.arcrun.dev/mcp"); + }); + it("protectedResourceMetadata 必要欄位", () => { + const m = protectedResourceMetadata("https://mcp.arcrun.dev"); + expect(m.resource).toBe("https://mcp.arcrun.dev/mcp"); + expect(m.authorization_servers).toEqual(["https://mcp.arcrun.dev"]); + }); + it("authorizationServerMetadata 必要欄位(S256/code/none)", () => { + const m = authorizationServerMetadata("https://mcp.arcrun.dev"); + expect(m.authorization_endpoint).toBe("https://mcp.arcrun.dev/authorize"); + expect(m.token_endpoint).toBe("https://mcp.arcrun.dev/token"); + expect(m.registration_endpoint).toBe("https://mcp.arcrun.dev/register"); + expect(m.response_types_supported).toEqual(["code"]); + expect(m.code_challenge_methods_supported).toEqual(["S256"]); + expect(m.token_endpoint_auth_methods_supported).toEqual(["none"]); + }); + it("wwwAuthenticateHeader 指向 protected-resource metadata", () => { + expect(wwwAuthenticateHeader("https://mcp.arcrun.dev")).toBe( + 'Bearer resource_metadata="https://mcp.arcrun.dev/.well-known/oauth-protected-resource"', + ); + }); + it("normalizeResource:尾斜線 / 大小寫 scheme+host / 預設 port 都正規化成 canonical", () => { + const canon = "https://mcp.arcrun.dev/mcp"; + expect(normalizeResource("https://mcp.arcrun.dev/mcp")).toBe(canon); + expect(normalizeResource("https://mcp.arcrun.dev/mcp/")).toBe(canon); // 尾斜線 + expect(normalizeResource("HTTPS://Mcp.Arcrun.Dev/mcp")).toBe(canon); // 大小寫 scheme+host + expect(normalizeResource("https://mcp.arcrun.dev:443/mcp")).toBe(canon); // 預設 port + expect(normalizeResource("not a url")).toBeNull(); + }); + it("resourceMatches:canonical / 尾斜線變體都 true;別的 host/path false", () => { + const origin = "https://mcp.arcrun.dev"; + expect(resourceMatches("https://mcp.arcrun.dev/mcp", origin)).toBe(true); + expect(resourceMatches("https://mcp.arcrun.dev/mcp/", origin)).toBe(true); + expect(resourceMatches("https://other.example.com/mcp", origin)).toBe(false); + expect(resourceMatches("https://mcp.arcrun.dev/other", origin)).toBe(false); + expect(resourceMatches("garbage", origin)).toBe(false); + }); +}); + +// ── consent escaping(XSS)──────────────────────────────────────────────────── +describe("oauth/consent", () => { + it("esc 跳脫 HTML 特殊字元", () => { + expect(esc(`