From 7d9d478baa264685b8ef2f417ca6718933e0fa33 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 03:59:00 +0000 Subject: [PATCH 1/3] feat(mcp): OAuth 2.1 server for claude.ai remote connector; close plaintext-namespace bearer hole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 arcrun-mcp worker 實作 MCP Authorization 規範(OAuth 2.1 + PKCE S256), 讓 claude.ai 遠端 connector 安全登入;並修掉「Bearer 明碼 namespace 直接放行」漏洞。 安全模型 - /authorize 同意頁以 owner secret(CF Secrets MCP_OWNER_SECRET)把關,只有 owner 知道 → 只知 URL 的人走不完 OAuth、拿不到 token。 - access_token 是 /mcp 唯一接受的 bearer(預設);明碼 namespace 舊路徑移除(步驟 5 直接 401)。 實作 endpoint(掛 worker 根路徑) - RFC 9728 /.well-known/oauth-protected-resource(+/mcp 變體)+ 401 帶 WWW-Authenticate: Bearer resource_metadata=... - RFC 8414 /.well-known/oauth-authorization-server(response_types=code, S256, none) - RFC 7591 /register(public client,無 secret,無狀態不落地) - GET/POST /authorize(PKCE S256 + owner-secret 閘 + redirect_uri 白名單) - POST /token(authorization_code + PKCE 驗證 → access_token 綁定 owner namespace) 儲存鐵律 - authorization code / access token → 短效 KV OAUTH_KV(key 用 SHA-256 hash、帶 TTL、code 一次性) - owner secret / static token → CF Secrets(非 KV、非明碼 var) - DCR client / refresh token → 不落地(無狀態 / 不實作,避免長效機密進 KV) 相容決策 - 本機 CLI/GUI/Claude Code → 用真祕密 MCP_STATIC_TOKEN(CF Secret)取代舊明碼 namespace - 官方 SaaS partner-key 路徑行為不變 - ALLOW_PLAINTEXT_NAMESPACE 逃生門預設關(僅遷移期) 驗證:tsc exit 0;vitest 42/42(oauth 22 + partner-auth 10 改測真實 middleware + 既有 10); wrangler deploy --dry-run 打包過、OAUTH_KV binding 正確識別。設計文件 mcp/OAUTH.md。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk --- mcp/OAUTH.md | 128 +++++++++ mcp/src/index.ts | 11 +- mcp/src/middleware/partner-auth.ts | 97 +++++-- mcp/src/oauth/consent.ts | 86 ++++++ mcp/src/oauth/crypto.ts | 61 +++++ mcp/src/oauth/metadata.ts | 49 ++++ mcp/src/oauth/routes.ts | 293 ++++++++++++++++++++ mcp/src/oauth/store.ts | 78 ++++++ mcp/src/types.ts | 23 ++ mcp/tests/unit/oauth.test.ts | 410 ++++++++++++++++++++++++++++ mcp/tests/unit/partner-auth.test.ts | 195 ++++++++----- mcp/wrangler.toml | 20 ++ 12 files changed, 1352 insertions(+), 99 deletions(-) create mode 100644 mcp/OAUTH.md create mode 100644 mcp/src/oauth/consent.ts create mode 100644 mcp/src/oauth/crypto.ts create mode 100644 mcp/src/oauth/metadata.ts create mode 100644 mcp/src/oauth/routes.ts create mode 100644 mcp/src/oauth/store.ts create mode 100644 mcp/tests/unit/oauth.test.ts diff --git a/mcp/OAUTH.md b/mcp/OAUTH.md new file mode 100644 index 0000000..c685877 --- /dev/null +++ b/mcp/OAUTH.md @@ -0,0 +1,128 @@ +# 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)**:code/token 記 `resource`/`aud` = 本 MCP server 的 canonical URI。 +- **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. **【預設關】`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 逃生門,預設關 = 預設安全。 + +**為何不做 refresh token**:refresh token 需長效持久化,依鐵律得進 CF Secrets/KBDB 而非 KV,成本與 +面積都大。改採「較長 TTL 的 access_token(預設 30 天)+ 到期重走 OAuth(owner 重輸祕密)」,兼顧安全 +(週期性重認證)與簡潔(無長效機密落地)。TTL 由 `MCP_TOKEN_TTL` 調。 + +## 6. 測試涵蓋 + +`mcp/tests/unit/oauth.test.ts`(22): +- 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` 格式。 +- 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。 + +`mcp/tests/unit/partner-auth.test.ts`(10,改測真實 middleware): +- 無/壞 Authorization → 401 + `WWW-Authenticate`(RFC 9728)。 +- OAuth token → 解出 namespace;未知 token(明碼)在 self-hosted → **401(洞已補)**。 +- `MCP_STATIC_TOKEN` 相容路徑通過 / 明碼被擋。 +- 官方 SaaS partner-key(mock KBDB)行為不變。 +- `ALLOW_PLAINTEXT_NAMESPACE` 逃生門開/關。 + +全部 42 tests 綠;`tsc --noEmit` exit 0;`wrangler deploy --dry-run` 打包過、`OAUTH_KV` binding 正確識別。 + +## 7. leo 部署前要做什麼(不在本 PR 內,本 PR 不部署) + +1. **建 KV namespace 並填 id**:`wrangler kv namespace create OAUTH_MCP` → 把 id 貼進 + `mcp/wrangler.toml` 的 `[[kv_namespaces]] OAUTH_KV`(目前是 `REPLACE_WITH_REAL_KV_ID` 佔位)。 + > self-hosted 自動注入(`deploy.ts injectWranglerConfig`)**尚未涵蓋此新 binding** → 需手動填,或 + > 補 injectWranglerConfig 加一條(跨 CLI 的後續,見報告待辦)。 +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..6e9a682 100644 --- a/mcp/src/middleware/partner-auth.ts +++ b/mcp/src/middleware/partner-auth.ts @@ -1,48 +1,89 @@ import { Context, Next } from "hono"; import { Env } from "../types.js"; +import { getAccessToken } from "../oauth/store.js"; +import { constantTimeEqual } from "../oauth/crypto.js"; +import { originOf, 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) { + 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)【預設關,不安全】遷移逃生門:恢復舊明碼 namespace 行為。 + 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..2ab2cee --- /dev/null +++ b/mcp/src/oauth/metadata.ts @@ -0,0 +1,49 @@ +// 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`; +} + +/** 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..536f16f --- /dev/null +++ b/mcp/src/oauth/routes.ts @@ -0,0 +1,293 @@ +// 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, + 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); + } + 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: q.resource ?? resourceUri(originOf(c.req.url)), + }; + 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); + } + 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: p.resource ?? resourceUri(originOf(c.req.url)), + }; + // ★ 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"); + } + 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, + aud: data.resource, // RFC 8707:綁定受眾 = 本 MCP server + 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..fb2a96b --- /dev/null +++ b/mcp/tests/unit/oauth.test.ts @@ -0,0 +1,410 @@ +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, + 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"', + ); + }); +}); + +// ── consent escaping(XSS)──────────────────────────────────────────────────── +describe("oauth/consent", () => { + it("esc 跳脫 HTML 特殊字元", () => { + expect(esc(`