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 index c685877..cd1ea0e 100644 --- a/mcp/OAUTH.md +++ b/mcp/OAUTH.md @@ -75,7 +75,7 @@ metadata 以「當前請求 origin」動態生成 → 同一份碼在 `mcp.arcru 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"`** → 恢復舊明碼路徑。**僅遷移期**,設了等於重開漏洞,正式勿用。 +4. **【預設關,SUNSET】`ALLOW_PLAINTEXT_NAMESPACE="true"`** → 恢復舊明碼路徑。**僅遷移期**,設了等於重開漏洞,正式勿用。 5. 皆不符 → 401 + `WWW-Authenticate`。 **明碼 namespace 當 bearer 的舊路徑已從預設移除**(步驟 5 直接 401)。之所以保留步驟 2/4: @@ -84,13 +84,26 @@ metadata 以「當前請求 origin」動態生成 → 同一份碼在 `mcp.arcru 明碼 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(預設 30 天)+ 到期重走 OAuth(owner 重輸祕密)」,兼顧安全 -(週期性重認證)與簡潔(無長效機密落地)。TTL 由 `MCP_TOKEN_TTL` 調。 +面積都大。**有意取捨**=改採「較長 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`(22): +`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` 格式。 @@ -99,22 +112,27 @@ metadata 以「當前請求 origin」動態生成 → 同一份碼在 `mcp.arcru 白名單擋非法 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`(10,改測真實 middleware): +`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` 逃生門開/關。 -全部 42 tests 綠;`tsc --noEmit` exit 0;`wrangler deploy --dry-run` 打包過、`OAUTH_KV` binding 正確識別。 +全部測試綠;`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 的後續,見報告待辦)。 +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)。 diff --git a/mcp/src/middleware/partner-auth.ts b/mcp/src/middleware/partner-auth.ts index 6e9a682..0206112 100644 --- a/mcp/src/middleware/partner-auth.ts +++ b/mcp/src/middleware/partner-auth.ts @@ -2,7 +2,7 @@ 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"; +import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js"; /** * MCP / GUI 端點認證中介層。 @@ -41,6 +41,13 @@ export async function partnerAuthMiddleware( 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(); @@ -76,7 +83,9 @@ export async function partnerAuthMiddleware( return; } - // 4)【預設關,不安全】遷移逃生門:恢復舊明碼 namespace 行為。 + // 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); diff --git a/mcp/tests/unit/oauth.test.ts b/mcp/tests/unit/oauth.test.ts index fb2a96b..cb43565 100644 --- a/mcp/tests/unit/oauth.test.ts +++ b/mcp/tests/unit/oauth.test.ts @@ -408,3 +408,89 @@ describe("oauth flow (整合)", () => { expect(r.status).toBe(503); }); }); + +// ── 防 drift:對 OAUTH_KV 的每一次 put 都必須帶 expirationTtl(守儲存鐵律)────────── +// spy KV 記錄所有 put(key,value,opts);跑完整流程後斷言沒有任何一次「無 TTL」的 put, +// 防未來有人往這顆短效 KV 塞長效資料(access_token / code 以外的東西)。 +describe("oauth store drift guard:OAUTH_KV 的 put 一律帶 TTL", () => { + function spyKV(): { kv: KVNamespace; puts: Array<{ key: string; opts?: { expirationTtl?: number } }> } { + const map = new Map(); + const puts: Array<{ key: string; opts?: { expirationTtl?: number } }> = []; + const kv = { + async put(key: string, value: string, opts?: { expirationTtl?: number }) { + puts.push({ key, opts }); + map.set(key, value); + }, + async get(key: string) { + return map.get(key) ?? null; + }, + async delete(key: string) { + map.delete(key); + }, + } as unknown as KVNamespace; + return { kv, puts }; + } + + it("完整 authorize→token 流程中,OAUTH_KV 的每次 put 都有 expirationTtl>0", async () => { + const { kv, puts } = spyKV(); + const env = baseEnv({ OAUTH_KV: kv }); + const app = buildApp(env); + const { verifier, challenge } = await pkcePair(); + const redirect = "https://claude.ai/cb"; + + const authRes = await app.req("/authorize", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: "c1", + redirect_uri: redirect, + code_challenge: challenge, + code_challenge_method: "S256", + owner_secret: "s3cr3t-owner", + }).toString(), + redirect: "manual", + }); + const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!; + await app.req("/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: verifier, + redirect_uri: redirect, + client_id: "c1", + }).toString(), + }); + + // 至少發生了 put(code + token 各一),且每一次都帶 TTL。 + expect(puts.length).toBeGreaterThanOrEqual(2); + for (const p of puts) { + expect(p.opts?.expirationTtl, `put ${p.key} 缺 expirationTtl`).toBeTypeOf("number"); + expect(p.opts!.expirationTtl!).toBeGreaterThan(0); + } + }); + + it("直接呼叫 store 層 putAuthCode / putAccessToken 也一律帶 TTL", async () => { + const { kv, puts } = spyKV(); + await putAuthCode(kv, "c", { + client_id: "c1", + redirect_uri: "https://claude.ai/cb", + code_challenge: "cc", + code_challenge_method: "S256", + scope: "mcp", + resource: "https://mcp/mcp", + namespace: "leo", + }); + await putAccessToken( + kv, + "t", + { namespace: "leo", client_id: "c1", scope: "mcp", aud: "https://mcp/mcp", exp: 1 }, + 100, + ); + expect(puts).toHaveLength(2); + for (const p of puts) { + expect(p.opts?.expirationTtl).toBeGreaterThan(0); + } + }); +}); diff --git a/mcp/tests/unit/partner-auth.test.ts b/mcp/tests/unit/partner-auth.test.ts index b10134e..1e2a247 100644 --- a/mcp/tests/unit/partner-auth.test.ts +++ b/mcp/tests/unit/partner-auth.test.ts @@ -70,10 +70,17 @@ describe("partner-auth: 無 / 壞 Authorization → 401 + WWW-Authenticate(RFC describe("partner-auth: OAuth access token 路徑(遠端 claude.ai)", () => { it("有效 access_token → 解出綁定 namespace", async () => { const kv = makeKV(); + // aud 必須 == 本次請求 origin 的 canonical resource URI(app.request 打 https://mcp.arcrun.dev/mcp) await putAccessToken( kv, "good-token", - { namespace: "leo", client_id: "c1", scope: "mcp", aud: "x", exp: Math.floor(Date.now() / 1000) + 100 }, + { + namespace: "leo", + client_id: "c1", + scope: "mcp", + aud: "https://mcp.arcrun.dev/mcp", + exp: Math.floor(Date.now() / 1000) + 100, + }, 100, ); const r = await buildApp(baseEnv({ MULTI_TENANT: "false", OAUTH_KV: kv }))("Bearer good-token"); @@ -81,6 +88,25 @@ describe("partner-auth: OAuth access token 路徑(遠端 claude.ai)", () => expect((await r.json()).org_namespace).toBe("leo"); }); + it("RFC 8707 aud 不符 → 401 invalid_token(防 token passthrough)", async () => { + const kv = makeKV(); + await putAccessToken( + kv, + "wrong-aud-token", + { + namespace: "leo", + client_id: "c1", + scope: "mcp", + aud: "https://other-mcp.example.com/mcp", // 別的部署簽的 token + exp: Math.floor(Date.now() / 1000) + 100, + }, + 100, + ); + const r = await buildApp(baseEnv({ MULTI_TENANT: "false", OAUTH_KV: kv }))("Bearer wrong-aud-token"); + expect(r.status).toBe(401); + expect(r.headers.get("WWW-Authenticate")).toContain("resource_metadata="); + }); + it("未知 token(非 OAuth、非 static)在 self-hosted → 401(明碼 namespace 不再放行)", async () => { const kv = makeKV(); const r = await buildApp(baseEnv({ MULTI_TENANT: "false", OAUTH_KV: kv }))("Bearer leo"); diff --git a/mcp/wrangler.toml b/mcp/wrangler.toml index e9cacf4..fcd953e 100644 --- a/mcp/wrangler.toml +++ b/mcp/wrangler.toml @@ -33,13 +33,15 @@ service = "arcrun-kbdb" # ── OAuth 2.1 server(claude.ai 遠端 connector 安全登入,見 mcp/OAUTH.md)────────────── # 短效認證儲存(authorization code + access token,皆帶 TTL、key 用 SHA-256 hash)。 -# ⚠️ 部署前 leo/mira 需先建立此 KV namespace 並填入真實 id: -# wrangler kv namespace create OAUTH_MCP -# 把回傳的 id 貼到下面(self-hosted 自動注入路徑尚未涵蓋此 binding → 需手動或補 deploy.ts injectWranglerConfig)。 +# id 是占位: +# • CLI 路徑(acr init / acr update)→ deploy.ts 已把 OAUTH_KV 納入 REQUIRED_KV_NAMESPACES, +# 自動建 namespace + injectWranglerConfig 換成用戶帳號真 id(零手動)。 +# • 手動直推(leo21c wrangler deploy,mistakes #23 codeload 陷阱下的路徑)→ 需自己先建並填 id: +# wrangler kv namespace create OAUTH_MCP → 把 id 貼到下面。 # 未設此 binding → OAuth /authorize、/token 回 503(誠實拒絕,不假綠),既有 MCP 路徑不受影響。 [[kv_namespaces]] binding = "OAUTH_KV" -id = "REPLACE_WITH_REAL_KV_ID" # ← 部署前必填(wrangler kv namespace create OAUTH_MCP) +id = "REPLACE_WITH_REAL_KV_ID" # CLI 部署會自動換;手動直推才需填(wrangler kv namespace create OAUTH_MCP) # ── OAuth 相關機密與設定 ─────────────────────────────────────────────────────────── # 機密走 CF Secrets(wrangler secret put,不進 toml、非 KV、非明碼 var): @@ -47,9 +49,10 @@ id = "REPLACE_WITH_REAL_KV_ID" # ← 部署前必填(wrangler kv namespace cr # wrangler secret put MCP_STATIC_TOKEN # (選配)本機 CLI/GUI/Claude Code 相容用的真祕密 token # 非機密設定可放 [vars](下方 [vars] table): # MCP_OWNER_NAMESPACE access_token 綁定的資料分區,預設 "leo" -# MCP_TOKEN_TTL access_token 存活秒數,預設 2592000(30 天) +# MCP_TOKEN_TTL access_token 存活秒數,預設 2592000(30 天,有意取捨見 OAUTH.md §5;7 天為更保守選項) # MCP_ALLOWED_REDIRECT_HOSTS 逗號分隔 redirect host 白名單,預設 claude.ai/claude.com/anthropic.com+localhost -# ALLOW_PLAINTEXT_NAMESPACE 【不安全】="true" 恢復舊明碼 namespace 漏洞路徑,僅遷移期用,正式勿開 +# ALLOW_PLAINTEXT_NAMESPACE 【不安全,SUNSET】="true" 恢復舊明碼 namespace 漏洞路徑,僅遷移期,驗收完即刪 +# (追蹤移除的 issue 見 OAUTH.md §5) # Route — MCP 搬進 arcrun 主庫後改用 arcrun.dev zone(與其他 worker 一致)。 # 舊的 studio.finally.click 是 inkstone 平台 zone,arcrun 帳號沒有該 zone → 部署 route 失敗。