feat(mcp): OAuth 2.1 server for claude.ai remote connector(修明碼-namespace bearer 漏洞) #15

Merged
Leo merged 3 commits from feat/mcp-oauth-server into main 2026-07-07 06:01:51 +00:00
13 changed files with 1700 additions and 99 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。*/
+159
View File
@@ -0,0 +1,159 @@
# arcrun-mcp OAuth 2.1 Serverclaude.ai 遠端 connector 安全登入)
> Design 文件。對應 PR `feat/mcp-oauth-server`。實作全在 `mcp/`(改既有 arcrun-mcp 這一顆 worker
> 的框架碼,非新 worker、非應用工作流、無 service binding)。
## 1. 為什麼(安全定調)
MCP 打進 arcrun = 觸及該租戶 **KBDB 全量讀寫**,是個資外泄面,必須有真認證。
**修掉的漏洞**`partner-auth.ts` 舊行為在 `MULTI_TENANT=false`self-hosted)時,把 `Authorization:
Bearer <x>``<x>` 直接當成 `org_namespace` 明碼放行。於是**任何知道 URL 的人送 `Bearer leo`
就能讀寫 leo 的全部資料**。這正是本次要關掉的洞。
**鐵律**:只知道「網址 + 明碼 namespace」的人,必須讀不到任何資料。
## 2. 安全模型(owner secret 怎麼把關)
claude.ai remote connector 走 **OAuth 2.1 + PKCES256**。整條鏈唯一的「人類祕密閘」在
`/authorize` 同意頁:要求輸入 **owner secret**`MCP_OWNER_SECRET`,存 CF Secrets,非 KV、非明碼 var)。
- 祕密**正確** → 才發 authorization code → claude.ai 用 PKCE `code_verifier``access_token`
- 祕密**錯誤 / 未帶** → 不發碼,重顯同意頁(401)。
- `MCP_OWNER_SECRET` **未設**`/authorize` 直接回 503(拒絕在無把關下發碼,不留不安全預設)。
**為何「只知 URL 的人」進不來**:他能打開 `/authorize` 頁、能自己跑 DCR 拿 `client_id`、能發起
PKCE,但**走到發碼那步需要 owner secret**,而 secret 只在 CF Secrets、只有 owner 知道。拿不到 code →
換不到 token → 打 `/mcp` 一律 401。access_token 是唯一被 `/mcp` 接受的 bearer(見 §5 相容決策)。
**縱深防禦**
- **PKCE S256 強制**`/authorize``/token` 都要求 `code_challenge_method=S256``plain`/缺省一律拒。
- **authorization code 一次性 + 極短 TTL600s)**:讀到即從 KV 刪,防重放。
- **redirect_uri 白名單**DCR 無狀態,見 §5):預設只允許 `claude.ai``claude.com``anthropic.com`
(含子網域)+ `localhost``http` 僅限本機。擋 open-redirect/釣魚把 code 送去攻擊者。可用
`MCP_ALLOWED_REDIRECT_HOSTS` 調整。
- **token audience 綁定 + 簽發端驗證(RFC 8707**
- `/authorize``/token` 收到 client 的 `resource` 參數時,**正規化後**(scheme/host 小寫、去預設 port、
path 去尾斜線;與 `metadata.ts resourceUri` 產出的 canonical 一致)必須 == 本 server canonical
`resourceUri(origin)`。**不符則簽發端 fail fast**`/authorize` redirect 帶 `error=invalid_target`
redirect_uri 已驗過才 redirect,否則 400)、`/token` 回 400 `invalid_target`
- **存進 `aud` 的一律是 canonical `resourceUri(origin)`**(不存 client 原樣值)→ 與 partner-auth 的嚴格比對
`at.aud === resourceUri(origin)` 恆一致。
- **為何要正規化而非純字串拒**:claude.ai 送 `…/mcp/`(尾斜線)或大小寫變體是等價 canonical → 正規化後接受、
存 canonical aud → `/mcp` 必過。若對等價形也拒,claude.ai 真送變體會**永久授權失敗連不上**(把「OAuth 成功
但 /mcp 401」的問題換位重現)。只有正規化後**真正不同**的 resource(別的 host/path)才 fail-fast 拒。
- **KV key 用 SHA-256 hash**codetoken 不以明碼當 key,KV list 也拿不到可用憑證(比照
`wasi-shim.ts` 只寫短效 oauth2 cache 的精神)。
- **owner secretstatic token 用常數時間比對**:防 timing attack。
## 3. Endpoint 清單(全掛在 worker origin 根,非 `/mcp` basePath
| Method | Path | 規範 | 作用 |
|---|---|---|---|
| GET | `/.well-known/oauth-protected-resource`+`/mcp` 後綴變體) | RFC 9728 | Protected Resource Metadata`resource` + `authorization_servers` |
| GET | `/.well-known/oauth-authorization-server`+`/mcp` 後綴變體) | RFC 8414 | AS Metadataauthorize/token/registration endpoint、`S256``code``none` |
| POST | `/register` | RFC 7591 | Dynamic Client Registration → 回 `client_id`public client,無 secret |
| GET | `/authorize` | OAuth 2.1 | 呈現 owner-secret 同意頁(要求 `response_type=code` + PKCE S256 + 合法 redirect_uri |
| POST | `/authorize` | OAuth 2.1 | 驗 owner secret → 發 code → 302 redirect 回 `redirect_uri?code=&state=` |
| POST | `/token` | OAuth 2.1 | `authorization_code` + `code_verifier`(PKCE) → `access_token` |
**未帶有效 token 的 `/mcp`(及 GUI REST 端點)**:回 **401 + `WWW-Authenticate: Bearer
resource_metadata="<origin>/.well-known/oauth-protected-resource"`**RFC 9728 §5.1),claude.ai 靠這個
發現 OAuth。
metadata 以「當前請求 origin」動態生成 → 同一份碼在 `mcp.arcrun.dev``arcrun-mcp.<sub>.workers.dev`
都正確。
## 4. 各資料存哪(儲存鐵律逐項)
| 資料 | 存哪 | 理由 |
|---|---|---|
| **authorization code** | 短效 KV `OAUTH_KV`key=`oauth:code:<sha256>`,TTL 600s,一次性 | 「取得的暫時性認證」,允許進短效 KV |
| **access token** | 短效 KV `OAUTH_KV`key=`oauth:tok:<sha256>`TTL=`MCP_TOKEN_TTL`(預設 30 天) | 同上;過期自動消失 |
| **owner secret** | **CF Secrets** `MCP_OWNER_SECRET``wrangler secret put`) | 長效機密,非 KV、非明碼 var |
| **static token(相容用)** | **CF Secrets** `MCP_STATIC_TOKEN`(選配) | 長效機密,同上 |
| **DCR client 註冊** | **不落地(無狀態)** | public client 無 secret,非機密;不需持久 → 不塞 KV(守鐵律) |
| **refresh token** | **不實作**(見 §5) | 避免長效機密落地;改短效 access_token + 到期重新授權 |
| owner namespace / token TTL / redirect 白名單 | `[vars]`(非機密設定) | 純設定值 |
## 5. 相容決策(明碼-namespace-bearer 舊路徑怎麼處理)
**預設安全優先,遠端一律走 OAuth。** `partner-auth.ts` 新認證順序:
1. **OAuth access_token**`OAUTH_KV` 查得到)→ 解出綁定 namespace。**遠端 claude.ai 的正規路徑。**
2. **`MCP_STATIC_TOKEN`(真祕密,CF Secret** → 解成 `MCP_OWNER_NAMESPACE`。**本機 CLI / GUI /
本機 Claude Code 的相容路徑**——用「真祕密 token」取代舊「明碼 namespace」,owner 可掌控(CF Secrets)。
3. **官方 SaaS`MULTI_TENANT` 未設/`true`** → KBDB partner-key 驗證,**行為完全不變**。
4. **【預設關,SUNSET】`ALLOW_PLAINTEXT_NAMESPACE="true"`** → 恢復舊明碼路徑。**僅遷移期**,設了等於重開漏洞,正式勿用。
5. 皆不符 → 401 + `WWW-Authenticate`
**明碼 namespace 當 bearer 的舊路徑已從預設移除**(步驟 5 直接 401)。之所以保留步驟 2/4:
- 本機 Claude Code MCP 目前經 `acr mcp-setup``namespace` 寫進 `.mcp.json``Authorization`
header。若硬砍會斷本機整合。**正解=改用 `MCP_STATIC_TOKEN`(真祕密)**`mcp-setup` 寫入真祕密而非
明碼 namespace 屬 CLI 側後續(本 PR 未動 CLI,於報告標為待辦)。
- `ALLOW_PLAINTEXT_NAMESPACE` 只是遷移期的明確 opt-in 逃生門,預設關 = 預設安全。
**逃生門有退場(SUNSET**`ALLOW_PLAINTEXT_NAMESPACE` 只是遷移期暫時相容,**驗收完即刪整段 code path
`Env` 欄位**。移除追蹤:**Gitea issue #18**https://git.uncle6.me/Leo/Arcrun/issues/18)。code 中該分支已標
`SUNSET` 註記。
### access_token TTL 是有意取捨(無 refresh token
**為何不做 refresh token**refresh token 需長效持久化,依鐵律得進 CF Secrets/KBDB 而非 KV,成本與
面積都大。**有意取捨**=改採「較長 TTL 的 access_token + 到期重走 OAuthowner 重輸一次 owner secret)」,
兼顧安全(週期性重認證)與簡潔(無長效機密落地)。
- **預設 `MCP_TOKEN_TTL` = 2592000 秒(30 天)**——本 PR 維持此預設(leo 拍板不改)。
- **可調**`[vars]``MCP_TOKEN_TTL` 即可;**7 天(604800)為更保守選項**(縮短 = 更頻繁重認證 = 更安全但 UX 略煩)。
- **到期行為**KV TTL 到 → token 自動失效 → `/mcp` 回 401 + `WWW-Authenticate` → claude.ai 重走 OAuth
(再輸一次 owner secret)。無 refresh token 故無長效機密落地。
- **後續(per-owner 可調,非本 PR)**TTL 風險偏好交用戶決定——console 設定頁 → 存 KBDB → `/token` 發 token
時讀 per-owner 覆蓋、回退 30 天。追蹤:**Gitea issue #19**https://git.uncle6.me/Leo/Arcrun/issues/19)。
## 6. 測試涵蓋
`mcp/tests/unit/oauth.test.ts`
- PKCE S256 驗證(正確 / 拒 plain / 拒缺省 method / verifier 長度邊界 / 竄改);已知 SHA-256 向量、RFC 7636 附錄範例。
- 短效 KV storeauthorization code 一次性(consume 後失效,防重放)、access token 存取 + `exp` 過期、key 為 hash。
- metadataorigin 推導、Protected Resource / AS Metadata 必要欄位、`WWW-Authenticate` 格式。
- **RFC 8707 resource 正規化/簽發端驗證**`normalizeResource`/`resourceMatches`(尾斜線/大小寫/預設 port
等價、別 host/path 不等價);`/authorize` 尾斜線變體→**正常發碼**且 token aud 為 canonical、
別 host 的 resource → GET/POST `/authorize` redirect `error=invalid_target` 不發碼、`/token` 帶別 host
resource → 400 `invalid_target`
- consent XSS escape(惡意 `state` 不注入)。
- 完整流程整合(Hono `app.request`):well-known 動態 origin、DCR 回 client_id 無 secret、redirect_uri
白名單擋非法 host、GET `/authorize` 要 PKCE、`MCP_OWNER_SECRET` 未設→503、**正確祕密+正確 verifier→
access_token**、**錯誤祕密→401 不發 code**、錯誤 verifier→invalid_grant、重用 code→invalid_grant、
`OAUTH_KV` 未設→503。
- **防 drift**spy KV 攔所有 `put`,斷言對 `OAUTH_KV` 的**每一次 put 都帶 `expirationTtl`> 0**——
防未來有人往這顆 KV 塞長效資料(守儲存鐵律)。
`mcp/tests/unit/partner-auth.test.ts`(改測真實 middleware):
- 無/壞 Authorization → 401 + `WWW-Authenticate`RFC 9728)。
- OAuth token → 解出 namespace;未知 token(明碼)在 self-hosted → **401(洞已補)**
- **RFC 8707 aud 驗證**token `aud` 不等於本次請求 origin 算出的 canonical resource URI → **401 `invalid_token`**
- `MCP_STATIC_TOKEN` 相容路徑通過 / 明碼被擋。
- 官方 SaaS partner-keymock KBDB)行為不變。
- `ALLOW_PLAINTEXT_NAMESPACE` 逃生門開/關。
全部測試綠;`tsc --noEmit` exit 0`wrangler deploy --dry-run` 打包過、`OAUTH_KV` binding 正確識別。
## 7. leo 部署前要做什麼(不在本 PR 內,本 PR 不部署)
1. **建 KV namespace + 填 id**(依部署路徑):
- **CLI 路徑(`acr init` / `acr update`)→ 已自動化**:本 PR 把 `OAUTH_KV` 納入 `deploy.ts`
`REQUIRED_KV_NAMESPACES` → init/update 自動建 namespace(冪等)+ `injectWranglerConfig`
`REPLACE_WITH_REAL_KV_ID` 換成用戶帳號真 id。零手動。
- **手動直推(leo21c wrangler deploymistakes #23 codeload 陷阱下走的路徑)→ 需手動**:
`wrangler kv namespace create OAUTH_MCP` → 把 id 貼進 `mcp/wrangler.toml``[[kv_namespaces]] OAUTH_KV`
2. **設 owner secretCF Secrets**`wrangler secret put MCP_OWNER_SECRET`(輸入只有你知道的強祕密)。
3.(選配)**設本機相容 static token**`wrangler secret put MCP_STATIC_TOKEN`,並把本機 `.mcp.json`
`Authorization: Bearer <此值>`(取代舊明碼 namespace)。
4.(選配)`[vars]` 調 `MCP_OWNER_NAMESPACE`(預設 leo/`MCP_TOKEN_TTL`(預設 2592000/
`MCP_ALLOWED_REDIRECT_HOSTS`
5. **重部署 arcrun-mcp**(走 leo21c wrangler 直推,勿 `acr update`——codeload 陷阱 mistakes #23)。
6. 驗收:`curl <origin>/.well-known/oauth-protected-resource` → 200;未帶 token 打 `/mcp` → 401 帶
`WWW-Authenticate`claude.ai 加 remote connector 走完 OAuth(輸 owner secret)能連上。
> ⚠️ 若 KV/secret 未就緒:OAuth 端點誠實回 503、`/mcp` 回 401(不假綠);既有官方 SaaS partner-key
> 路徑不受影響。
+10 -1
View File
@@ -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;
+78 -28
View File
@@ -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_tokenOAUTH_KV 查得到)→ 解出綁定 namespace。遠端 claude.ai 走這條。
* 2. MCP_STATIC_TOKENCF Secret,真祕密)→ 解成 MCP_OWNER_NAMESPACE。本機 CLI/GUI/Claude Code 相容用。
* 3. 官方 SaaSMULTI_TENANT 未設 / "true")→ KBDB partner-key 驗證(行為不變)。
* 4. 【預設關】ALLOW_PLAINTEXT_NAMESPACE="true" → 恢復舊「Bearer 明碼即 namespace」(=已修掉的漏洞,僅遷移期)。
* 5. 皆不符 → 401 + WWW-AuthenticateRFC 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.mdHANDOFF §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 tokenCF 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;
}
// 官方 SaaSMULTI_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) 官方 SaaSKBDB 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");
}
+86
View File
@@ -0,0 +1,86 @@
// /authorize 同意頁:極簡單檔 HTML,要求輸入 owner 祕密才發碼。零外部資源。
// 所有反射進 HTML 的 OAuth 參數都 escape,防 XSSredirect_uri / state / client_id 由外部帶入)。
/** HTML attribute / text 跳脫。 */
export function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** 同意頁需要 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 `<input type="hidden" name="${esc(name)}" value="${esc(value)}">`;
}
/**
* 同意頁 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
? `<p class="err" role="alert">${esc(error)}</p>`
: "";
return `<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arcrun MCP 授權</title>
<style>
:root { color-scheme: light dark; }
body { font-family: -apple-system, "Segoe UI", system-ui, sans-serif; max-width: 26rem;
margin: 4rem auto; padding: 0 1.25rem; line-height: 1.6; }
h1 { font-size: 1.25rem; }
p.desc { color: #666; font-size: .95rem; }
code { background: rgba(127,127,127,.15); padding: .1rem .35rem; border-radius: .25rem;
font-size: .85rem; word-break: break-all; }
label { display: block; margin: 1.25rem 0 .35rem; font-weight: 600; }
input[type=password] { width: 100%; padding: .6rem .7rem; font-size: 1rem;
border: 1px solid #8888; border-radius: .5rem; box-sizing: border-box; }
button { margin-top: 1.25rem; width: 100%; padding: .7rem; font-size: 1rem; font-weight: 600;
border: 0; border-radius: .5rem; background: #8a5f1e; color: #fff; cursor: pointer; }
button:hover { background: #6f4c18; }
p.err { color: #c0392b; font-weight: 600; }
p.foot { color: #999; font-size: .8rem; margin-top: 2rem; }
</style>
</head>
<body>
<h1>Arcrun MCP 授權</h1>
<p class="desc">應用程式 <code>${esc(p.client_id)}</code> 想連上你的 Arcrun MCP
這會讓它能<strong>讀寫你的 KBDB 全部資料</strong>。</p>
${errBlock}
<form method="POST" action="/authorize">
${fields}
<label for="owner_secret">Owner 祕密</label>
<input id="owner_secret" name="owner_secret" type="password" autocomplete="off"
autofocus required placeholder="只有你知道的祕密">
<button type="submit">授權連線</button>
</form>
<p class="foot">祕密不正確不會發出授權碼。此頁不儲存你的輸入。</p>
</body>
</html>`;
}
+61
View File
@@ -0,0 +1,61 @@
// OAuth 加密工具:PKCE S256 驗證、SHA-256 hash、隨機不可猜 token、常數時間比對。
// 全走 Web CryptoWorkers / Node 18+ 全域 crypto.subtle),無外部依賴。
/** base64url 編碼(無 paddingRFC 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(/=+$/, "");
}
/** 產生密碼學等級隨機 tokenbytes 位元組 → base64url 字串,預設 32 bytes = 256-bit)。 */
export function randomToken(bytes = 32): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return base64UrlEncode(buf);
}
/** SHA-256 → base64urlPKCE code_challenge 用;RFC 7636 §4.2 BASE64URL(SHA256(verifier)))。 */
export async function sha256Base64Url(input: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
return base64UrlEncode(new Uint8Array(digest));
}
/** SHA-256 → hex(拿來當 KV key:不把可用的 raw token 直接當 keyKV list 也看不到明碼)。 */
export async function sha256Hex(input: string): Promise<string> {
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;
}
/**
* 驗證 PKCEBASE64URL(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<boolean> {
if (method !== "S256") return false;
if (!codeVerifier || !codeChallenge) return false;
// RFC 7636 §4.1verifier 43128 字元、[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);
}
+81
View File
@@ -0,0 +1,81 @@
// OAuth 探索文件(RFC 9728 Protected Resource Metadata、RFC 8414 Authorization Server Metadata
// 以「當前請求的 origin」動態組出——同一份碼在 mcp.arcrun.dev 與 arcrun-mcp.<sub>.workers.dev 都正確。
/** 從請求 URL 取 originscheme://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 URIRFC 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 / fragmentresource
* 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;
}
+325
View File
@@ -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.1http 只准 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<Record<string, string>> {
const ct = req.headers.get("content-type") ?? "";
try {
if (ct.includes("application/json")) {
const j = (await req.json()) as Record<string, unknown>;
const out: Record<string, string> = {};
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<string, string> = {};
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, string>): string {
const u = new URL(redirectUri);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
return u.toString();
}
/**
* OAuth appMCP app
* `Bindings: Env` Hono Variables app
*/
export function registerOAuthRoutes<
E extends HonoBaseEnv & { Bindings: Env },
>(app: Hono<E>): 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 一律 JSONRFC 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 8707client 傳了 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 8707resource(隱藏欄位帶回,仍可能被竄改)→ 正規化後須 == 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 /tokencode + 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 8707token 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 8707aud 一律用「本 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 };
+78
View File
@@ -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 serveraudience)。 */
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 codeTTL 極短)。回傳 raw code 給 client。 */
export async function putAuthCode(kv: KVNamespace, code: string, data: AuthCodeData): Promise<void> {
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<AuthCodeData | null> {
const key = CODE_PREFIX + (await sha256Hex(code));
const raw = await kv.get(key);
if (!raw) return null;
await kv.delete(key); // 一次性使用(OAuth 2.1code 用過必失效)
try {
return JSON.parse(raw) as AuthCodeData;
} catch {
return null;
}
}
/** 存 access tokenTTL = ttlSeconds)。回傳 raw token 給 client。 */
export async function putAccessToken(
kv: KVNamespace,
token: string,
data: AccessTokenData,
ttlSeconds: number,
): Promise<void> {
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<AccessTokenData | null> {
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;
}
}
+23
View File
@@ -15,6 +15,29 @@ export interface Env {
// 未設 / "true" = 官方 SaaS:維持 partner-key 驗證(行為完全不變)。
// SDD: sdk-and-website/mcp-account-source.mdHANDOFF §3b。
MULTI_TENANT?: string;
// ── OAuth 2.1 serverclaude.ai 遠端 connector 安全登入,見 mcp/OAUTH.md)──────────
// 短效認證儲存:authorization codeTTL ~600s+ access tokenTTL = MCP_TOKEN_TTL)。
// 只放「取得的暫時性認證」,key 用 SHA-256 hashKV 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 綁定的 namespaceowner 的資料分區)。預設 "leo"。
MCP_OWNER_NAMESPACE?: string;
// access_token 存活秒數(同時是 KV TTL)。字串(toml var)。預設 259200030 天)。
// 過期後 claude.ai 重走 OAuthowner 重輸祕密)——刻意不做 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 {
+620
View File
@@ -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<string, { value: string; exp?: number }>();
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;
}
// PKCEverifier "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> = {}): 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("verifyPkceS256S256 正確、拒 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("resourceMatchescanonical / 尾斜線變體都 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(`<script>"&'`)).toBe("&lt;script&gt;&quot;&amp;&#39;");
});
it("consentPage 把惡意 state 跳脫掉(不注入)", () => {
const html = consentPage({
client_id: "c1",
redirect_uri: "https://claude.ai/cb",
state: '"><img src=x onerror=alert(1)>',
code_challenge: "cc",
code_challenge_method: "S256",
scope: "mcp",
resource: "r",
});
expect(html).not.toContain("<img src=x");
expect(html).toContain("&lt;img src=x");
});
});
// ── 完整 OAuth 流程(route 整合)──────────────────────────────────────────────
function buildApp(env: Env) {
const app = new Hono<{ Bindings: Env }>();
registerOAuthRoutes(app);
return {
req: (path: string, init?: RequestInit) =>
app.request("https://mcp.arcrun.dev" + path, init, env),
};
}
describe("oauth flow (整合)", () => {
it("well-known metadata 端點以 request origin 動態生成", async () => {
const app = buildApp(baseEnv());
const r = await app.req("/.well-known/oauth-protected-resource");
expect(r.status).toBe(200);
const j = await r.json();
expect(j.resource).toBe("https://mcp.arcrun.dev/mcp");
const r2 = await app.req("/.well-known/oauth-authorization-server");
expect((await r2.json()).issuer).toBe("https://mcp.arcrun.dev");
});
it("/register 回 client_idpublic client,無 secret", async () => {
const app = buildApp(baseEnv());
const r = await app.req("/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ redirect_uris: ["https://claude.ai/cb"], client_name: "Claude" }),
});
expect(r.status).toBe(201);
const j = await r.json();
expect(j.client_id).toMatch(/^mcp_/);
expect(j.token_endpoint_auth_method).toBe("none");
expect(j).not.toHaveProperty("client_secret");
});
it("/register 擋不允許的 redirect_uri host", async () => {
const app = buildApp(baseEnv());
const r = await app.req("/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ redirect_uris: ["https://evil.example.com/cb"] }),
});
expect(r.status).toBe(400);
expect((await r.json()).error).toBe("invalid_redirect_uri");
});
it("GET /authorize 要求 PKCE S256 且顯示同意頁", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const ok = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
"https://claude.ai/cb",
)}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`,
);
expect(ok.status).toBe(200);
expect(await ok.text()).toContain("Owner 祕密");
// 缺 PKCE → 400
const bad = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
"https://claude.ai/cb",
)}`,
);
expect(bad.status).toBe(400);
});
it("GET /authorizeMCP_OWNER_SECRET 未設 → 503(不留不安全預設)", async () => {
const app = buildApp(baseEnv({ MCP_OWNER_SECRET: undefined }));
const { challenge } = await pkcePair();
const r = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
"https://claude.ai/cb",
)}&code_challenge=${challenge}&code_challenge_method=S256`,
);
expect(r.status).toBe(503);
});
it("完整 code→token:正確 owner 祕密 + 正確 verifier → access_token", async () => {
const env = baseEnv();
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
// POST /authorize 正確祕密 → 302 帶 code
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,
state: "st-1",
code_challenge: challenge,
code_challenge_method: "S256",
scope: "mcp",
resource: "https://mcp.arcrun.dev/mcp",
owner_secret: "s3cr3t-owner",
}).toString(),
redirect: "manual",
});
expect(authRes.status).toBe(302);
const loc = new URL(authRes.headers.get("location")!);
expect(loc.searchParams.get("state")).toBe("st-1");
const code = loc.searchParams.get("code")!;
expect(code).toBeTruthy();
// POST /token 正確 verifier → access_token
const tokRes = 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(),
});
expect(tokRes.status).toBe(200);
const tok = await tokRes.json();
expect(tok.token_type).toBe("Bearer");
expect(tok.access_token).toBeTruthy();
expect(tok.expires_in).toBeGreaterThan(0);
// 換發的 token 綁定 owner namespace
const at = await getAccessToken(env.OAUTH_KV!, tok.access_token);
expect(at?.namespace).toBe("leo");
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp");
});
it("錯誤 owner 祕密 → 401、不發 code", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: "https://claude.ai/cb",
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "WRONG",
}).toString(),
redirect: "manual",
});
expect(r.status).toBe(401);
expect(r.headers.get("location")).toBeNull();
expect(await r.text()).toContain("不正確");
});
it("/token 錯誤 verifier → invalid_grant;重用 code → invalid_grant", async () => {
const env = baseEnv();
const app = buildApp(env);
const { 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")!;
// 錯誤 verifier
const bad = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: "z".repeat(64),
redirect_uri: redirect,
}).toString(),
});
expect(bad.status).toBe(400);
expect((await bad.json()).error).toBe("invalid_grant");
// 該 code 已被 consume(即使失敗也一次性)→ 再用正確 verifier 也 invalid_grant
const reuse = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: "a".repeat(64),
redirect_uri: redirect,
}).toString(),
});
expect((await reuse.json()).error).toBe("invalid_grant");
});
it("未設 OAUTH_KV → /token 回 503(誠實,不假綠)", async () => {
const app = buildApp(baseEnv({ OAUTH_KV: undefined }));
const r = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: "x",
code_verifier: "a".repeat(64),
redirect_uri: "https://claude.ai/cb",
}).toString(),
});
expect(r.status).toBe(503);
});
});
// ── RFC 8707 resource 簽發端驗證/正規化 ─────────────────────────────────────────
// 決策:resource 正規化後 == canonical → 接受(尾斜線/大小寫/預設 port 皆等價,aud 一律存 canonical
// /mcp 嚴格比對必過);正規化後 != canonical(別的 host/path)→ 簽發端 fail-fast 拒 invalid_target。
// 若把尾斜線也拒,claude.ai 真送尾斜線變體會永久連不上(把 401 問題換位重現),故等價形接受才對。
describe("oauth resourceRFC 8707)簽發端把關", () => {
const redirect = "https://claude.ai/cb";
async function postAuthorize(app: ReturnType<typeof buildApp>, resource: string, challenge: string) {
return app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
state: "st",
code_challenge: challenge,
code_challenge_method: "S256",
resource,
owner_secret: "s3cr3t-owner",
}).toString(),
redirect: "manual",
});
}
it("尾斜線變體(等價 canonical)→ 正常發碼,且換出 token 的 aud 為 canonical", async () => {
const env = baseEnv();
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const r = await postAuthorize(app, "https://mcp.arcrun.dev/mcp/", challenge);
expect(r.status).toBe(302);
const loc = new URL(r.headers.get("location")!);
expect(loc.searchParams.get("error")).toBeNull(); // 未被拒
const code = loc.searchParams.get("code")!;
expect(code).toBeTruthy();
const tokRes = 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,
resource: "https://mcp.arcrun.dev/mcp/", // token 端也帶尾斜線 → 正規化後仍通過
}).toString(),
});
expect(tokRes.status).toBe(200);
const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token);
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp"); // 存的是 canonical,與 partner-auth 嚴格比對一致
});
it("正確 canonical resource → 正常發碼", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await postAuthorize(app, "https://mcp.arcrun.dev/mcp", challenge);
expect(r.status).toBe(302);
expect(new URL(r.headers.get("location")!).searchParams.get("code")).toBeTruthy();
});
it("GET /authorize:別的 host 的 resource → redirect 帶 error=invalid_target(不顯示同意頁)", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(redirect)}` +
`&code_challenge=${challenge}&code_challenge_method=S256&state=st` +
`&resource=${encodeURIComponent("https://evil.example.com/mcp")}`,
);
expect(r.status).toBe(302);
const loc = new URL(r.headers.get("location")!);
expect(loc.searchParams.get("error")).toBe("invalid_target");
expect(loc.searchParams.get("state")).toBe("st");
expect(loc.searchParams.get("code")).toBeNull();
});
it("POST /authorize:別的 host 的 resource → redirect invalid_target,不發碼", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await postAuthorize(app, "https://evil.example.com/mcp", challenge);
expect(r.status).toBe(302);
const loc = new URL(r.headers.get("location")!);
expect(loc.searchParams.get("error")).toBe("invalid_target");
expect(loc.searchParams.get("code")).toBeNull();
});
it("POST /token:別的 host 的 resource → 400 invalid_target", async () => {
const env = baseEnv();
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
// 先正常拿一個 codeauthorize 不帶 resource → 存 canonical
const authRes = await postAuthorize(app, "https://mcp.arcrun.dev/mcp", challenge);
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
const tokRes = 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,
resource: "https://evil.example.com/mcp", // token 端 resource 不符 → 拒
}).toString(),
});
expect(tokRes.status).toBe(400);
expect((await tokRes.json()).error).toBe("invalid_target");
});
});
// ── 防 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);
}
});
});
+151 -70
View File
@@ -1,80 +1,161 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { Hono } from "hono";
import { partnerAuthMiddleware } from "../../src/middleware/partner-auth.js";
import { putAccessToken } from "../../src/oauth/store.js";
import type { Env } from "../../src/types.js";
// Unit tests for partner-auth middleware logic
// Tests the auth extraction and validation behaviour without a live KBDB
function extractBearerToken(authHeader: string | undefined): string | null {
if (!authHeader?.startsWith("Bearer ")) return null;
return authHeader.slice(7);
// ── 記憶體 KV mock ────────────────────────────────────────────────────────────
function makeKV(): KVNamespace {
const map = new Map<string, string>();
return {
async put(k: string, v: string) {
map.set(k, v);
},
async get(k: string) {
return map.get(k) ?? null;
},
async delete(k: string) {
map.delete(k);
},
} as unknown as KVNamespace;
}
describe("partner-auth: token extraction", () => {
it("returns null when Authorization header is missing", () => {
expect(extractBearerToken(undefined)).toBeNull();
});
it("returns null when header does not start with 'Bearer '", () => {
expect(extractBearerToken("Basic abc123")).toBeNull();
expect(extractBearerToken("bearer abc123")).toBeNull();
expect(extractBearerToken("Token abc123")).toBeNull();
});
it("extracts token from valid Bearer header", () => {
expect(extractBearerToken("Bearer my-secret-key")).toBe("my-secret-key");
});
it("handles token with special characters", () => {
expect(extractBearerToken("Bearer abc.def_ghi-123")).toBe("abc.def_ghi-123");
});
});
describe("partner-auth: KBDB response validation", () => {
it("rejects when valid is false", () => {
const info = { valid: false, org_namespace: "org-a" };
expect(info.valid).toBe(false);
});
it("accepts when valid is true and extracts org_namespace", () => {
const info = { valid: true, org_namespace: "org-a" };
expect(info.valid).toBe(true);
expect(info.org_namespace).toBe("org-a");
});
});
// HANDOFF §3b / mcp-account-source.md §5.5self-hostedMULTI_TENANT=false)下
// Bearer 帶的是 namespace 明碼,不打 KBDB partner 驗證,直接當 org_namespace。
// 與 cypher-executor 的 opaque-key 模型對齊(X-Arcrun-API-Key 不驗證直接當分區 key)。
function resolveNamespace(
multiTenant: string | undefined,
token: string,
validatePartner: (t: string) => { valid: boolean; org_namespace: string },
): { ok: boolean; org_namespace?: string } {
if (multiTenant === "false") {
// self-hostedBearer 明碼即 namespace,繞 partner 驗證
return { ok: true, org_namespace: token };
}
// SaaS:維持 partner-key 驗證(行為不變)
const info = validatePartner(token);
return info.valid ? { ok: true, org_namespace: info.org_namespace } : { ok: false };
// 假 KBDB Fetcher:依 URL 回傳 partner 驗證結果。
function makeKBDB(valid: boolean, ns = "org-a"): Fetcher {
return {
async fetch() {
return new Response(JSON.stringify({ valid, org_namespace: ns }), {
status: valid ? 200 : 401,
});
},
} as unknown as Fetcher;
}
describe("partner-auth: self-hosted (MULTI_TENANT=false) bypasses partner validation", () => {
const partnerValidatorThatAlwaysRejects = () => ({ valid: false, org_namespace: "" });
function baseEnv(over: Partial<Env> = {}): Env {
return {
COMPONENT_REGISTRY: {} as Fetcher,
CYPHER_EXECUTOR: {} as Fetcher,
KBDB: makeKBDB(true),
KBDB_INTERNAL_TOKEN: "internal",
...over,
} as Env;
}
it("self-hosted: namespace 明碼直接當 org_namespace,不打 partner 驗證", () => {
const r = resolveNamespace("false", "leo", partnerValidatorThatAlwaysRejects);
expect(r.ok).toBe(true);
expect(r.org_namespace).toBe("leo");
// 建一個掛了 partnerAuthMiddleware 的最小 appprotected handler 回傳解出的 namespace。
function buildApp(env: Env) {
const app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>();
app.get("/mcp", partnerAuthMiddleware, (c) =>
c.json({ org_namespace: c.get("org_namespace"), partner_token: c.get("partner_token") }),
);
return (auth?: string) =>
app.request("https://mcp.arcrun.dev/mcp", auth ? { headers: { Authorization: auth } } : {}, env);
}
describe("partner-auth: 無 / 壞 Authorization → 401 + WWW-AuthenticateRFC 9728", () => {
it("缺 header → 401 帶 WWW-Authenticate resource_metadata", async () => {
const r = await buildApp(baseEnv({ MULTI_TENANT: "false" }))();
expect(r.status).toBe(401);
expect(r.headers.get("WWW-Authenticate")).toBe(
'Bearer resource_metadata="https://mcp.arcrun.dev/.well-known/oauth-protected-resource"',
);
});
it("SaaS (未設 MULTI_TENANT):仍走 partner 驗證,明碼被擋", () => {
const r = resolveNamespace(undefined, "leo", partnerValidatorThatAlwaysRejects);
expect(r.ok).toBe(false);
});
it("SaaS:合法 partner key 通過並取 org_namespace", () => {
const r = resolveNamespace("true", "pk_live_x", () => ({ valid: true, org_namespace: "org-a" }));
expect(r.ok).toBe(true);
expect(r.org_namespace).toBe("org-a");
it("非 Bearer → 401", async () => {
const r = await buildApp(baseEnv())("Basic abc");
expect(r.status).toBe(401);
expect(r.headers.get("WWW-Authenticate")).toContain("resource_metadata=");
});
});
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: "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");
expect(r.status).toBe(200);
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");
expect(r.status).toBe(401);
expect(r.headers.get("WWW-Authenticate")).toContain("resource_metadata=");
});
});
describe("partner-auth: MCP_STATIC_TOKEN 相容路徑(本機 CLI/GUI 真祕密)", () => {
it("Bearer == static token → owner namespace", async () => {
const r = await buildApp(
baseEnv({ MULTI_TENANT: "false", MCP_STATIC_TOKEN: "real-secret-xyz", MCP_OWNER_NAMESPACE: "leo" }),
)("Bearer real-secret-xyz");
expect(r.status).toBe(200);
expect((await r.json()).org_namespace).toBe("leo");
});
it("Bearer != static token(明碼 namespace)→ 401", async () => {
const r = await buildApp(
baseEnv({ MULTI_TENANT: "false", MCP_STATIC_TOKEN: "real-secret-xyz" }),
)("Bearer leo");
expect(r.status).toBe(401);
});
});
describe("partner-auth: 官方 SaaS partner-key(行為不變)", () => {
it("合法 partner key → org_namespace", async () => {
const r = await buildApp(baseEnv({ KBDB: makeKBDB(true, "org-a") }))("Bearer pk_live_x");
expect(r.status).toBe(200);
expect((await r.json()).org_namespace).toBe("org-a");
});
it("非法 partner key → 401", async () => {
const r = await buildApp(baseEnv({ KBDB: makeKBDB(false) }))("Bearer pk_bad");
expect(r.status).toBe(401);
});
});
describe("partner-auth: 明碼逃生門(預設關)", () => {
it("ALLOW_PLAINTEXT_NAMESPACE=true → 恢復舊明碼行為(遷移期)", async () => {
const r = await buildApp(
baseEnv({ MULTI_TENANT: "false", ALLOW_PLAINTEXT_NAMESPACE: "true" }),
)("Bearer leo");
expect(r.status).toBe(200);
expect((await r.json()).org_namespace).toBe("leo");
});
it("預設(未設逃生門)明碼 namespace 被擋", async () => {
const r = await buildApp(baseEnv({ MULTI_TENANT: "false" }))("Bearer leo");
expect(r.status).toBe(401);
});
});
+23
View File
@@ -31,6 +31,29 @@ service = "arcrun-cypher-executor"
binding = "KBDB"
service = "arcrun-kbdb"
# ── OAuth 2.1 serverclaude.ai 遠端 connector 安全登入,見 mcp/OAUTH.md)──────────────
# 短效認證儲存(authorization code + access token,皆帶 TTL、key 用 SHA-256 hash)。
# 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" # CLI 部署會自動換;手動直推才需填(wrangler kv namespace create OAUTH_MCP
# ── OAuth 相關機密與設定 ───────────────────────────────────────────────────────────
# 機密走 CF Secretswrangler secret put,不進 toml、非 KV、非明碼 var):
# wrangler secret put MCP_OWNER_SECRET # /authorize 同意頁把關密碼(只有 owner 知道)
# 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 天,有意取捨見 OAUTH.md §5;7 天為更保守選項)
# MCP_ALLOWED_REDIRECT_HOSTS 逗號分隔 redirect host 白名單,預設 claude.ai/claude.com/anthropic.comlocalhost
# ALLOW_PLAINTEXT_NAMESPACE 【不安全,SUNSET】="true" 恢復舊明碼 namespace 漏洞路徑,僅遷移期,驗收完即刪
# (追蹤移除的 issue 見 OAUTH.md §5
# Route — MCP 搬進 arcrun 主庫後改用 arcrun.dev zone(與其他 worker 一致)。
# 舊的 studio.finally.click 是 inkstone 平台 zonearcrun 帳號沒有該 zone → 部署 route 失敗。
# 對外正式 MCP URL = mcp.arcrun.dev;對內 / fork 仍可走 workers_dev=true 的 arcrun-mcp.<sub>.workers.dev。