fix(mcp): address PR #15 review — aud validation, OAUTH_KV auto-inject, TTL/​sunset docs, drift test

leo review 5 條逐條處理:

1. RFC 8707 aud 驗證(真缺口):partner-auth OAuth 路徑補「at.aud === resourceUri(originOf(url))」,
   不符回 401 invalid_token(防別的 arcrun-mcp 部署簽的 token passthrough)。加 aud 不符→401 測試。

2. deploy.ts injectWranglerConfig 涵蓋 OAUTH_KV(在 PR 內補):OAUTH_KV 納入 REQUIRED_KV_NAMESPACES →
   acr init/update 自動建 namespace + 注入用戶帳號真 id(比照 SUBMISSIONS_KV 家族)。wrangler.toml 註解
   更新(CLI 路徑自動、手動直推才需手建)。注入 regex 已驗證命中。

3. MCP_TOKEN_TTL 預設維持 30 天(leo 拍板不改):OAUTH.md 明寫為有意取捨(無 refresh token → 到期重走
   OAuth=再輸 owner secret),MCP_TOKEN_TTL 可調、7 天為更保守選項。per-owner 可調另開 issue #19(非阻塞)。

4. ALLOW_PLAINTEXT_NAMESPACE 逃生門標 SUNSET(code + wrangler.toml + OAUTH.md),開 issue #18 追蹤
   「遷移完成後移除整段 code path + Env 欄位」。

5. 防 drift 測試:spy KV 攔所有 put,斷言對 OAUTH_KV 的每一次 put 都帶 expirationTtl>0(完整流程 +
   store 層兩道),防未來往這顆短效 KV 塞長效資料。

驗證:mcp tsc exit 0、vitest 45/45(+aud +drift×2);cli tsc exit 0。

Refs #15 #18 #19

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
This commit is contained in:
Claude
2026-07-07 04:56:59 +00:00
parent 7d9d478baa
commit 92cfb9c59f
6 changed files with 166 additions and 19 deletions
+5
View File
@@ -73,6 +73,10 @@ const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun';
* SUBMISSIONS_KVregistry worker 用(component 投稿)。漏建會讓 registry deploy 失敗 →
* 壓測 §2.6/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它,
* 殘留官方舊 id → wrangler deploy 因 KV 不存在而失敗)。補進來後回到 21/21。
* OAUTH_KVarcrun-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。*/
+28 -10
View File
@@ -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 天)+ 到期重走 OAuthowner 重輸祕密)」,兼顧安全
(週期性重認證)與簡潔(無長效機密落地)。TTL 由 `MCP_TOKEN_TTL` 調。
面積都大。**有意取捨**改採「較長 TTL 的 access_token + 到期重走 OAuthowner 重輸一次 owner secret)」,
兼顧安全(週期性重認證)與簡潔(無長效機密落地)。
- **預設 `MCP_TOKEN_TTL` = 2592000 秒(30 天)**——本 PR 維持此預設(leo 拍板不改)。
- **可調**`[vars]``MCP_TOKEN_TTL` 即可;**7 天(604800)為更保守選項**(縮短 = 更頻繁重認證 = 更安全但 UX 略煩)。
- **到期行為**KV TTL 到 → token 自動失效 → `/mcp` 回 401 + `WWW-Authenticate` → claude.ai 重走 OAuth
(再輸一次 owner secret)。無 refresh token 故無長效機密落地。
- **後續(per-owner 可調,非本 PR)**TTL 風險偏好交用戶決定——console 設定頁 → 存 KBDB → `/token` 發 token
時讀 per-owner 覆蓋、回退 30 天。追蹤:**Gitea issue #19**https://git.uncle6.me/Leo/Arcrun/issues/19)。
## 6. 測試涵蓋
`mcp/tests/unit/oauth.test.ts`22
`mcp/tests/unit/oauth.test.ts`
- PKCE S256 驗證(正確 / 拒 plain / 拒缺省 method / verifier 長度邊界 / 竄改);已知 SHA-256 向量、RFC 7636 附錄範例。
- 短效 KV storeauthorization code 一次性(consume 後失效,防重放)、access token 存取 + `exp` 過期、key 為 hash。
- metadataorigin 推導、Protected Resource / AS Metadata 必要欄位、`WWW-Authenticate` 格式。
@@ -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-keymock 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 deploymistakes #23 codeload 陷阱下走的路徑)→ 需手動**:
`wrangler kv namespace create OAUTH_MCP` → 把 id 貼進 `mcp/wrangler.toml``[[kv_namespaces]] OAUTH_KV`
2. **設 owner secretCF Secrets**`wrangler secret put MCP_OWNER_SECRET`(輸入只有你知道的強祕密)。
3.(選配)**設本機相容 static token**`wrangler secret put MCP_STATIC_TOKEN`,並把本機 `.mcp.json`
`Authorization: Bearer <此值>`(取代舊明碼 namespace)。
+11 -2
View File
@@ -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);
+86
View File
@@ -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 guardOAUTH_KV 的 put 一律帶 TTL", () => {
function spyKV(): { kv: KVNamespace; puts: Array<{ key: string; opts?: { expirationTtl?: number } }> } {
const map = new Map<string, string>();
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(),
});
// 至少發生了 putcode + 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);
}
});
});
+27 -1
View File
@@ -70,10 +70,17 @@ describe("partner-auth: 無 / 壞 Authorization → 401 + WWW-AuthenticateRFC
describe("partner-auth: OAuth access token 路徑(遠端 claude.ai", () => {
it("有效 access_token → 解出綁定 namespace", async () => {
const kv = makeKV();
// aud 必須 == 本次請求 origin 的 canonical resource URIapp.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");
+9 -6
View File
@@ -33,13 +33,15 @@ service = "arcrun-kbdb"
# ── OAuth 2.1 serverclaude.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 deploymistakes #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 Secretswrangler 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 存活秒數,預設 259200030 天)
# MCP_TOKEN_TTL access_token 存活秒數,預設 259200030 天,有意取捨見 OAUTH.md §5;7 天為更保守選項
# MCP_ALLOWED_REDIRECT_HOSTS 逗號分隔 redirect host 白名單,預設 claude.ai/claude.com/anthropic.comlocalhost
# 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 平台 zonearcrun 帳號沒有該 zone → 部署 route 失敗。