Merge pull request 'feat(mcp): OAuth 2.1 server for claude.ai remote connector(修明碼-namespace bearer 漏洞)' (#15) from feat/mcp-oauth-server into main
This commit was merged in pull request #15.
This commit is contained in:
@@ -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。*/
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# arcrun-mcp OAuth 2.1 Server(claude.ai 遠端 connector 安全登入)
|
||||
|
||||
> Design 文件。對應 PR `feat/mcp-oauth-server`。實作全在 `mcp/`(改既有 arcrun-mcp 這一顆 worker
|
||||
> 的框架碼,非新 worker、非應用工作流、無 service binding)。
|
||||
|
||||
## 1. 為什麼(安全定調)
|
||||
|
||||
MCP 打進 arcrun = 觸及該租戶 **KBDB 全量讀寫**,是個資外泄面,必須有真認證。
|
||||
|
||||
**修掉的漏洞**:`partner-auth.ts` 舊行為在 `MULTI_TENANT=false`(self-hosted)時,把 `Authorization:
|
||||
Bearer <x>` 的 `<x>` 直接當成 `org_namespace` 明碼放行。於是**任何知道 URL 的人送 `Bearer leo`
|
||||
就能讀寫 leo 的全部資料**。這正是本次要關掉的洞。
|
||||
|
||||
**鐵律**:只知道「網址 + 明碼 namespace」的人,必須讀不到任何資料。
|
||||
|
||||
## 2. 安全模型(owner secret 怎麼把關)
|
||||
|
||||
claude.ai remote connector 走 **OAuth 2.1 + PKCE(S256)**。整條鏈唯一的「人類祕密閘」在
|
||||
`/authorize` 同意頁:要求輸入 **owner secret**(`MCP_OWNER_SECRET`,存 CF Secrets,非 KV、非明碼 var)。
|
||||
|
||||
- 祕密**正確** → 才發 authorization code → claude.ai 用 PKCE `code_verifier` 換 `access_token`。
|
||||
- 祕密**錯誤 / 未帶** → 不發碼,重顯同意頁(401)。
|
||||
- `MCP_OWNER_SECRET` **未設** → `/authorize` 直接回 503(拒絕在無把關下發碼,不留不安全預設)。
|
||||
|
||||
**為何「只知 URL 的人」進不來**:他能打開 `/authorize` 頁、能自己跑 DCR 拿 `client_id`、能發起
|
||||
PKCE,但**走到發碼那步需要 owner secret**,而 secret 只在 CF Secrets、只有 owner 知道。拿不到 code →
|
||||
換不到 token → 打 `/mcp` 一律 401。access_token 是唯一被 `/mcp` 接受的 bearer(見 §5 相容決策)。
|
||||
|
||||
**縱深防禦**:
|
||||
- **PKCE S256 強制**:`/authorize` 與 `/token` 都要求 `code_challenge_method=S256`;`plain`/缺省一律拒。
|
||||
- **authorization code 一次性 + 極短 TTL(600s)**:讀到即從 KV 刪,防重放。
|
||||
- **redirect_uri 白名單**(DCR 無狀態,見 §5):預設只允許 `claude.ai`/`claude.com`/`anthropic.com`
|
||||
(含子網域)+ `localhost`;`http` 僅限本機。擋 open-redirect/釣魚把 code 送去攻擊者。可用
|
||||
`MCP_ALLOWED_REDIRECT_HOSTS` 調整。
|
||||
- **token audience 綁定 + 簽發端驗證(RFC 8707)**:
|
||||
- `/authorize`、`/token` 收到 client 的 `resource` 參數時,**正規化後**(scheme/host 小寫、去預設 port、
|
||||
path 去尾斜線;與 `metadata.ts resourceUri` 產出的 canonical 一致)必須 == 本 server canonical
|
||||
`resourceUri(origin)`。**不符則簽發端 fail fast**:`/authorize` redirect 帶 `error=invalid_target`
|
||||
(redirect_uri 已驗過才 redirect,否則 400)、`/token` 回 400 `invalid_target`。
|
||||
- **存進 `aud` 的一律是 canonical `resourceUri(origin)`**(不存 client 原樣值)→ 與 partner-auth 的嚴格比對
|
||||
`at.aud === resourceUri(origin)` 恆一致。
|
||||
- **為何要正規化而非純字串拒**:claude.ai 送 `…/mcp/`(尾斜線)或大小寫變體是等價 canonical → 正規化後接受、
|
||||
存 canonical aud → `/mcp` 必過。若對等價形也拒,claude.ai 真送變體會**永久授權失敗連不上**(把「OAuth 成功
|
||||
但 /mcp 401」的問題換位重現)。只有正規化後**真正不同**的 resource(別的 host/path)才 fail-fast 拒。
|
||||
- **KV key 用 SHA-256 hash**:code/token 不以明碼當 key,KV list 也拿不到可用憑證(比照
|
||||
`wasi-shim.ts` 只寫短效 oauth2 cache 的精神)。
|
||||
- **owner secret/static token 用常數時間比對**:防 timing attack。
|
||||
|
||||
## 3. Endpoint 清單(全掛在 worker origin 根,非 `/mcp` basePath)
|
||||
|
||||
| Method | Path | 規範 | 作用 |
|
||||
|---|---|---|---|
|
||||
| GET | `/.well-known/oauth-protected-resource`(+`/mcp` 後綴變體) | RFC 9728 | Protected Resource Metadata:`resource` + `authorization_servers` |
|
||||
| GET | `/.well-known/oauth-authorization-server`(+`/mcp` 後綴變體) | RFC 8414 | AS Metadata:authorize/token/registration endpoint、`S256`、`code`、`none` |
|
||||
| POST | `/register` | RFC 7591 | Dynamic Client Registration → 回 `client_id`(public client,無 secret) |
|
||||
| GET | `/authorize` | OAuth 2.1 | 呈現 owner-secret 同意頁(要求 `response_type=code` + PKCE S256 + 合法 redirect_uri) |
|
||||
| POST | `/authorize` | OAuth 2.1 | 驗 owner secret → 發 code → 302 redirect 回 `redirect_uri?code=&state=` |
|
||||
| POST | `/token` | OAuth 2.1 | `authorization_code` + `code_verifier`(PKCE) → `access_token` |
|
||||
|
||||
**未帶有效 token 的 `/mcp`(及 GUI REST 端點)**:回 **401 + `WWW-Authenticate: Bearer
|
||||
resource_metadata="<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 + 到期重走 OAuth(owner 重輸一次 owner secret)」,
|
||||
兼顧安全(週期性重認證)與簡潔(無長效機密落地)。
|
||||
|
||||
- **預設 `MCP_TOKEN_TTL` = 2592000 秒(30 天)**——本 PR 維持此預設(leo 拍板不改)。
|
||||
- **可調**:`[vars]` 改 `MCP_TOKEN_TTL` 即可;**7 天(604800)為更保守選項**(縮短 = 更頻繁重認證 = 更安全但 UX 略煩)。
|
||||
- **到期行為**:KV TTL 到 → token 自動失效 → `/mcp` 回 401 + `WWW-Authenticate` → claude.ai 重走 OAuth
|
||||
(再輸一次 owner secret)。無 refresh token 故無長效機密落地。
|
||||
- **後續(per-owner 可調,非本 PR)**:TTL 風險偏好交用戶決定——console 設定頁 → 存 KBDB → `/token` 發 token
|
||||
時讀 per-owner 覆蓋、回退 30 天。追蹤:**Gitea issue #19**(https://git.uncle6.me/Leo/Arcrun/issues/19)。
|
||||
|
||||
## 6. 測試涵蓋
|
||||
|
||||
`mcp/tests/unit/oauth.test.ts`:
|
||||
- PKCE S256 驗證(正確 / 拒 plain / 拒缺省 method / verifier 長度邊界 / 竄改);已知 SHA-256 向量、RFC 7636 附錄範例。
|
||||
- 短效 KV store:authorization code 一次性(consume 後失效,防重放)、access token 存取 + `exp` 過期、key 為 hash。
|
||||
- metadata:origin 推導、Protected Resource / AS Metadata 必要欄位、`WWW-Authenticate` 格式。
|
||||
- **RFC 8707 resource 正規化/簽發端驗證**:`normalizeResource`/`resourceMatches`(尾斜線/大小寫/預設 port
|
||||
等價、別 host/path 不等價);`/authorize` 尾斜線變體→**正常發碼**且 token aud 為 canonical、
|
||||
別 host 的 resource → GET/POST `/authorize` redirect `error=invalid_target` 不發碼、`/token` 帶別 host
|
||||
resource → 400 `invalid_target`。
|
||||
- consent XSS escape(惡意 `state` 不注入)。
|
||||
- 完整流程整合(Hono `app.request`):well-known 動態 origin、DCR 回 client_id 無 secret、redirect_uri
|
||||
白名單擋非法 host、GET `/authorize` 要 PKCE、`MCP_OWNER_SECRET` 未設→503、**正確祕密+正確 verifier→
|
||||
access_token**、**錯誤祕密→401 不發 code**、錯誤 verifier→invalid_grant、重用 code→invalid_grant、
|
||||
`OAUTH_KV` 未設→503。
|
||||
- **防 drift**:spy KV 攔所有 `put`,斷言對 `OAUTH_KV` 的**每一次 put 都帶 `expirationTtl`(> 0)**——
|
||||
防未來有人往這顆 KV 塞長效資料(守儲存鐵律)。
|
||||
|
||||
`mcp/tests/unit/partner-auth.test.ts`(改測真實 middleware):
|
||||
- 無/壞 Authorization → 401 + `WWW-Authenticate`(RFC 9728)。
|
||||
- OAuth token → 解出 namespace;未知 token(明碼)在 self-hosted → **401(洞已補)**。
|
||||
- **RFC 8707 aud 驗證**:token `aud` 不等於本次請求 origin 算出的 canonical resource URI → **401 `invalid_token`**。
|
||||
- `MCP_STATIC_TOKEN` 相容路徑通過 / 明碼被擋。
|
||||
- 官方 SaaS partner-key(mock KBDB)行為不變。
|
||||
- `ALLOW_PLAINTEXT_NAMESPACE` 逃生門開/關。
|
||||
|
||||
全部測試綠;`tsc --noEmit` exit 0;`wrangler deploy --dry-run` 打包過、`OAUTH_KV` binding 正確識別。
|
||||
|
||||
## 7. leo 部署前要做什麼(不在本 PR 內,本 PR 不部署)
|
||||
|
||||
1. **建 KV namespace + 填 id**(依部署路徑):
|
||||
- **CLI 路徑(`acr init` / `acr update`)→ 已自動化**:本 PR 把 `OAUTH_KV` 納入 `deploy.ts`
|
||||
`REQUIRED_KV_NAMESPACES` → init/update 自動建 namespace(冪等)+ `injectWranglerConfig` 把
|
||||
`REPLACE_WITH_REAL_KV_ID` 換成用戶帳號真 id。零手動。
|
||||
- **手動直推(leo21c wrangler deploy,mistakes #23 codeload 陷阱下走的路徑)→ 需手動**:
|
||||
`wrangler kv namespace create OAUTH_MCP` → 把 id 貼進 `mcp/wrangler.toml` 的 `[[kv_namespaces]] OAUTH_KV`。
|
||||
2. **設 owner secret(CF Secrets)**:`wrangler secret put MCP_OWNER_SECRET`(輸入只有你知道的強祕密)。
|
||||
3.(選配)**設本機相容 static token**:`wrangler secret put MCP_STATIC_TOKEN`,並把本機 `.mcp.json` 的
|
||||
`Authorization: Bearer <此值>`(取代舊明碼 namespace)。
|
||||
4.(選配)`[vars]` 調 `MCP_OWNER_NAMESPACE`(預設 leo)/`MCP_TOKEN_TTL`(預設 2592000)/
|
||||
`MCP_ALLOWED_REDIRECT_HOSTS`。
|
||||
5. **重部署 arcrun-mcp**(走 leo21c wrangler 直推,勿 `acr update`——codeload 陷阱 mistakes #23)。
|
||||
6. 驗收:`curl <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
@@ -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;
|
||||
|
||||
@@ -1,48 +1,98 @@
|
||||
import { Context, Next } from "hono";
|
||||
import { Env } from "../types.js";
|
||||
import { getAccessToken } from "../oauth/store.js";
|
||||
import { constantTimeEqual } from "../oauth/crypto.js";
|
||||
import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js";
|
||||
|
||||
/**
|
||||
* MCP / GUI 端點認證中介層。
|
||||
*
|
||||
* 安全定調(leo 鐵律):**只知道「網址 + 明碼 namespace」的人,必須讀不到任何資料。**
|
||||
* 認證順序(先到先得):
|
||||
* 1. OAuth 2.1 access_token(OAUTH_KV 查得到)→ 解出綁定 namespace。遠端 claude.ai 走這條。
|
||||
* 2. MCP_STATIC_TOKEN(CF Secret,真祕密)→ 解成 MCP_OWNER_NAMESPACE。本機 CLI/GUI/Claude Code 相容用。
|
||||
* 3. 官方 SaaS(MULTI_TENANT 未設 / "true")→ KBDB partner-key 驗證(行為不變)。
|
||||
* 4. 【預設關】ALLOW_PLAINTEXT_NAMESPACE="true" → 恢復舊「Bearer 明碼即 namespace」(=已修掉的漏洞,僅遷移期)。
|
||||
* 5. 皆不符 → 401 + WWW-Authenticate(RFC 9728),讓 claude.ai 發現 OAuth。
|
||||
*
|
||||
* ⚠️ 舊行為(MULTI_TENANT=false 時把 Bearer 明碼直接當 org_namespace)是本次要修掉的漏洞,
|
||||
* 已從預設路徑移除;只在明確設 ALLOW_PLAINTEXT_NAMESPACE="true" 的遷移情境才恢復。
|
||||
*/
|
||||
export async function partnerAuthMiddleware(
|
||||
c: Context<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>,
|
||||
next: Next
|
||||
) {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return c.json({ error: 'Missing or invalid Authorization header' }, 401);
|
||||
const origin = originOf(c.req.url);
|
||||
const unauthorized = (desc: string, error?: string) =>
|
||||
c.json({ error: "unauthorized", error_description: desc }, 401, {
|
||||
"WWW-Authenticate": wwwAuthenticateHeader(origin, error),
|
||||
});
|
||||
|
||||
const authHeader = c.req.header("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return unauthorized("Missing or invalid Authorization header");
|
||||
}
|
||||
const token = authHeader.slice(7);
|
||||
if (!token) {
|
||||
return unauthorized("Empty bearer token");
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
// 1) OAuth 2.1 access token(遠端 connector 的正規路徑)。
|
||||
if (c.env.OAUTH_KV) {
|
||||
const at = await getAccessToken(c.env.OAUTH_KV, token);
|
||||
if (at) {
|
||||
// RFC 8707 audience 驗證:token 只能用在它被簽發的目標 MCP server。
|
||||
// token 的 aud 是簽發時綁定的 resource;須等於「本次請求 origin 算出的 canonical resource URI」,
|
||||
// 否則拒絕(防別的 arcrun-mcp 部署簽的 token 拿來這裡用 = token passthrough)。
|
||||
const expectedAud = resourceUri(origin);
|
||||
if (at.aud !== expectedAud) {
|
||||
return unauthorized("Access token audience mismatch", "invalid_token");
|
||||
}
|
||||
c.set("org_namespace", at.namespace);
|
||||
c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份)
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Self-hosted 單租戶(MULTI_TENANT=false):Bearer 帶的是 namespace 明碼,不是平台 partner key。
|
||||
// 與 cypher-executor 一致——cypher 把 X-Arcrun-API-Key 當「不驗證的 opaque 分區 key」(namespace
|
||||
// 是明碼分區標籤非密碼,mindset §3 arcrun 不做授權判斷)。故 self-hosted 模式不打 KBDB partner
|
||||
// 驗證,直接把 token 當 org_namespace。SDD: mcp-account-source.md;HANDOFF §3b。
|
||||
if (c.env.MULTI_TENANT === 'false') {
|
||||
c.set('org_namespace', token);
|
||||
c.set('partner_token', token); // 下游轉發給 cypher 當 X-Arcrun-API-Key(與 CLI 同一份身份)
|
||||
// 2) 本機相容:真祕密 static token(CF Secret)→ owner namespace。取代舊明碼路徑。
|
||||
if (c.env.MCP_STATIC_TOKEN && constantTimeEqual(token, c.env.MCP_STATIC_TOKEN)) {
|
||||
const ns = c.env.MCP_OWNER_NAMESPACE || "leo";
|
||||
c.set("org_namespace", ns);
|
||||
c.set("partner_token", ns);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 官方 SaaS(MULTI_TENANT 未設 / "true"):維持 partner-key 驗證(行為不變)。
|
||||
const resp = await c.env.KBDB.fetch(
|
||||
`http://kbdb/partners/${encodeURIComponent(token)}/info`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`
|
||||
}
|
||||
// 3) 官方 SaaS:KBDB partner-key 驗證(行為完全不變)。
|
||||
if (c.env.MULTI_TENANT !== "false") {
|
||||
const resp = await c.env.KBDB.fetch(
|
||||
`http://kbdb/partners/${encodeURIComponent(token)}/info`,
|
||||
{ headers: { Authorization: `Bearer ${c.env.KBDB_INTERNAL_TOKEN}` } }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
return unauthorized("Invalid or expired partner key", "invalid_token");
|
||||
}
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
return c.json({ error: 'Invalid or expired partner key' }, 401);
|
||||
const info = await resp.json<{ valid: boolean; org_namespace: string }>();
|
||||
if (!info.valid) {
|
||||
return unauthorized("Invalid or expired partner key", "invalid_token");
|
||||
}
|
||||
c.set("org_namespace", info.org_namespace);
|
||||
c.set("partner_token", token);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await resp.json<{ valid: boolean; org_namespace: string }>();
|
||||
if (!info.valid) {
|
||||
return c.json({ error: 'Invalid or expired partner key' }, 401);
|
||||
// 4)【預設關,不安全,SUNSET】遷移逃生門:恢復舊明碼 namespace 行為。
|
||||
// ⚠️ 這是暫時相容路徑,遷移驗收完即刪整段(含 Env.ALLOW_PLAINTEXT_NAMESPACE 欄位)。
|
||||
// 追蹤 issue:見 OAUTH.md §5 / wrangler.toml 註解。留著只為遷移期,別當長期選項。
|
||||
if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") {
|
||||
c.set("org_namespace", token);
|
||||
c.set("partner_token", token);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
c.set('org_namespace', info.org_namespace);
|
||||
c.set('partner_token', token); // 給下游(cypher-executor / KBDB)轉發用
|
||||
await next();
|
||||
// 5) self-hosted 但沒帶 OAuth token / static token → 拒絕(明碼 namespace 不再放行)。
|
||||
return unauthorized("Bearer token not recognized; complete OAuth to obtain an access token", "invalid_token");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// /authorize 同意頁:極簡單檔 HTML,要求輸入 owner 祕密才發碼。零外部資源。
|
||||
// 所有反射進 HTML 的 OAuth 參數都 escape,防 XSS(redirect_uri / state / client_id 由外部帶入)。
|
||||
|
||||
/** HTML attribute / text 跳脫。 */
|
||||
export function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** 同意頁需要 round-trip 回 POST /authorize 的隱藏欄位。 */
|
||||
export interface ConsentParams {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
state: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
scope: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
function hidden(name: string, value: string): string {
|
||||
return `<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>`;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// OAuth 加密工具:PKCE S256 驗證、SHA-256 hash、隨機不可猜 token、常數時間比對。
|
||||
// 全走 Web Crypto(Workers / Node 18+ 全域 crypto.subtle),無外部依賴。
|
||||
|
||||
/** base64url 編碼(無 padding,RFC 7636 §A)。 */
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let bin = "";
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/** 產生密碼學等級隨機 token(bytes 位元組 → base64url 字串,預設 32 bytes = 256-bit)。 */
|
||||
export function randomToken(bytes = 32): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buf);
|
||||
return base64UrlEncode(buf);
|
||||
}
|
||||
|
||||
/** SHA-256 → base64url(PKCE code_challenge 用;RFC 7636 §4.2 BASE64URL(SHA256(verifier)))。 */
|
||||
export async function sha256Base64Url(input: string): Promise<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 直接當 key,KV 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗證 PKCE:BASE64URL(SHA256(code_verifier)) === code_challenge(僅支援 S256)。
|
||||
* method 非 "S256"(含缺省的 "plain")一律拒絕——OAuth 2.1 + 本 server 只宣告 S256。
|
||||
*/
|
||||
export async function verifyPkceS256(
|
||||
codeVerifier: string,
|
||||
codeChallenge: string,
|
||||
method: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (method !== "S256") return false;
|
||||
if (!codeVerifier || !codeChallenge) return false;
|
||||
// RFC 7636 §4.1:verifier 43–128 字元、[A-Za-z0-9-._~]。
|
||||
if (codeVerifier.length < 43 || codeVerifier.length > 128) return false;
|
||||
if (!/^[A-Za-z0-9\-._~]+$/.test(codeVerifier)) return false;
|
||||
const computed = await sha256Base64Url(codeVerifier);
|
||||
return constantTimeEqual(computed, codeChallenge);
|
||||
}
|
||||
@@ -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 取 origin(scheme://host),canonical 用小寫 scheme/host。 */
|
||||
export function originOf(reqUrl: string): string {
|
||||
const u = new URL(reqUrl);
|
||||
return `${u.protocol.toLowerCase()}//${u.host.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** 本 MCP server 的 canonical resource URI(RFC 8707 audience)——MCP 端點在 /mcp。 */
|
||||
export function resourceUri(origin: string): string {
|
||||
return `${origin}/mcp`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 client 傳的 `resource`(RFC 8707)正規化成 canonical 形式,好和 `resourceUri(origin)` 嚴格比對。
|
||||
* 正規化規則與 originOf/resourceUri 產出的 canonical 一致:
|
||||
* - scheme + host 小寫(URL 也自動去掉預設 port,如 https 的 :443)
|
||||
* - path 去尾斜線(`/mcp/` → `/mcp`;根 `/` 保留)
|
||||
* - 丟棄 query / fragment(resource 不該帶)
|
||||
* 非法 URL → null。
|
||||
*/
|
||||
export function normalizeResource(value: string): string | null {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const scheme = u.protocol.toLowerCase(); // 含結尾冒號,如 "https:"
|
||||
const host = u.host.toLowerCase(); // 含非預設 port;預設 port 已被 URL 去掉
|
||||
let path = u.pathname;
|
||||
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
||||
return `${scheme}//${host}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判斷 client 傳的 `resource` 正規化後是否 == 本 server 的 canonical resource URI。
|
||||
* 用於 /authorize、/token 的簽發端把關(fail fast),避免尾斜線/大小寫變體造成
|
||||
* 「OAuth 成功但每次打 /mcp 都 401 aud mismatch」的最難 debug 劇本。
|
||||
*/
|
||||
export function resourceMatches(resourceParam: string, origin: string): boolean {
|
||||
const n = normalizeResource(resourceParam);
|
||||
return n !== null && n === resourceUri(origin);
|
||||
}
|
||||
|
||||
/** RFC 9728 Protected Resource Metadata。 */
|
||||
export function protectedResourceMetadata(origin: string) {
|
||||
return {
|
||||
resource: resourceUri(origin),
|
||||
authorization_servers: [origin],
|
||||
scopes_supported: ["mcp"],
|
||||
bearer_methods_supported: ["header"],
|
||||
};
|
||||
}
|
||||
|
||||
/** RFC 8414 Authorization Server Metadata(本 worker 同時是 AS)。 */
|
||||
export function authorizationServerMetadata(origin: string) {
|
||||
return {
|
||||
issuer: origin,
|
||||
authorization_endpoint: `${origin}/authorize`,
|
||||
token_endpoint: `${origin}/token`,
|
||||
registration_endpoint: `${origin}/register`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"], // public client + PKCE
|
||||
scopes_supported: ["mcp"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9728 §5.1 WWW-Authenticate 回應標頭——401 時指向 protected-resource metadata,
|
||||
* claude.ai 靠這個發現 OAuth authorization server。
|
||||
*/
|
||||
export function wwwAuthenticateHeader(origin: string, error?: string): string {
|
||||
const metaUrl = `${origin}/.well-known/oauth-protected-resource`;
|
||||
let h = `Bearer resource_metadata="${metaUrl}"`;
|
||||
if (error) h += `, error="${error}"`;
|
||||
return h;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// OAuth 2.1 + PKCE server 路由。掛在 worker 根路徑(非 /mcp basePath),
|
||||
// 因為 well-known / authorize / token / register 都得在 origin 根,claude.ai 才發現得到。
|
||||
// 安全模型與相容決策見 mcp/OAUTH.md。
|
||||
import { Hono } from "hono";
|
||||
import type { Env as HonoBaseEnv } from "hono";
|
||||
import { Env } from "../types.js";
|
||||
import {
|
||||
randomToken,
|
||||
verifyPkceS256,
|
||||
constantTimeEqual,
|
||||
} from "./crypto.js";
|
||||
import {
|
||||
putAuthCode,
|
||||
consumeAuthCode,
|
||||
putAccessToken,
|
||||
AUTH_CODE_TTL_SECONDS,
|
||||
} from "./store.js";
|
||||
import {
|
||||
originOf,
|
||||
resourceUri,
|
||||
resourceMatches,
|
||||
protectedResourceMetadata,
|
||||
authorizationServerMetadata,
|
||||
} from "./metadata.js";
|
||||
import { consentPage, ConsentParams } from "./consent.js";
|
||||
|
||||
const DEFAULT_TOKEN_TTL = 2592000; // 30 天
|
||||
const DEFAULT_REDIRECT_HOSTS = ["claude.ai", "claude.com", "anthropic.com"];
|
||||
|
||||
const CORS_JSON = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Cache-Control": "no-store",
|
||||
} as const;
|
||||
|
||||
function ownerNamespace(env: Env): string {
|
||||
return env.MCP_OWNER_NAMESPACE || "leo";
|
||||
}
|
||||
|
||||
function tokenTtl(env: Env): number {
|
||||
const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL;
|
||||
}
|
||||
|
||||
/** redirect_uri 白名單檢查(OAuth 2.1:http 只准 localhost,其餘須 https 且 host 在白名單)。 */
|
||||
function isAllowedRedirect(uri: string, env: Env): boolean {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(uri);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const host = u.hostname.toLowerCase();
|
||||
const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
||||
if (u.protocol === "http:") return isLocal; // http 僅限本機
|
||||
if (u.protocol !== "https:") return false;
|
||||
if (isLocal) return true;
|
||||
const configured = (env.MCP_ALLOWED_REDIRECT_HOSTS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const list = configured.length ? configured : DEFAULT_REDIRECT_HOSTS;
|
||||
return list.some((h) => host === h || host.endsWith("." + h));
|
||||
}
|
||||
|
||||
/** 從 form 或 JSON body 讀參數(token/register 端點的 content-type 兩種都容忍)。 */
|
||||
async function readParams(req: Request): Promise<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 路由到根 app。呼叫端須把「MCP 端點以外」的根路徑交給此 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 一律 JSON(RFC 7591)。單次解析取 redirect_uris(陣列)+ client_name。
|
||||
let raw: { redirect_uris?: unknown; client_name?: unknown } = {};
|
||||
try {
|
||||
raw = (await c.req.raw.json()) as typeof raw;
|
||||
} catch {
|
||||
/* 無 / 非 JSON body:容忍,redirect_uris 視為空 */
|
||||
}
|
||||
const redirectUris: string[] = Array.isArray(raw.redirect_uris)
|
||||
? raw.redirect_uris.filter((x): x is string => typeof x === "string")
|
||||
: [];
|
||||
const clientName = typeof raw.client_name === "string" ? raw.client_name : "MCP Client";
|
||||
for (const uri of redirectUris) {
|
||||
if (!isAllowedRedirect(uri, c.env)) {
|
||||
return c.json(
|
||||
{ error: "invalid_redirect_uri", error_description: `redirect_uri not allowed: ${uri}` },
|
||||
400,
|
||||
CORS_JSON,
|
||||
);
|
||||
}
|
||||
}
|
||||
const clientId = "mcp_" + randomToken(16);
|
||||
return c.json(
|
||||
{
|
||||
client_id: clientId,
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
redirect_uris: redirectUris,
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
client_name: clientName,
|
||||
},
|
||||
201,
|
||||
CORS_JSON,
|
||||
);
|
||||
});
|
||||
|
||||
// ── GET /authorize:呈現 owner 祕密同意頁 ───────────────────────────────────────
|
||||
app.get("/authorize", (c) => {
|
||||
const q = c.req.query();
|
||||
if (q.response_type !== "code") {
|
||||
return c.text("unsupported_response_type: only 'code' is supported", 400);
|
||||
}
|
||||
if (q.code_challenge_method !== "S256" || !q.code_challenge) {
|
||||
return c.text("invalid_request: PKCE S256 code_challenge required", 400);
|
||||
}
|
||||
if (!q.redirect_uri || !isAllowedRedirect(q.redirect_uri, c.env)) {
|
||||
// redirect_uri 本身可疑 → 絕不 redirect(防 open redirect),直接顯示錯誤。
|
||||
return c.text("invalid_request: redirect_uri missing or not allowed", 400);
|
||||
}
|
||||
const canonicalResource = resourceUri(originOf(c.req.url));
|
||||
// RFC 8707:client 傳了 resource 就必須正規化後 == 本 server canonical,否則簽發端 fail fast。
|
||||
// redirect_uri 已驗過 → 用 OAuth 錯誤 redirect 帶 error=invalid_target(比顯示 400 更符規範)。
|
||||
if (q.resource && !resourceMatches(q.resource, originOf(c.req.url))) {
|
||||
return c.redirect(
|
||||
redirectWith(q.redirect_uri, {
|
||||
error: "invalid_target",
|
||||
error_description: "resource does not match this MCP server",
|
||||
...(q.state ? { state: q.state } : {}),
|
||||
}),
|
||||
302,
|
||||
);
|
||||
}
|
||||
if (!c.env.MCP_OWNER_SECRET) {
|
||||
return c.text("server_error: MCP_OWNER_SECRET not configured", 503);
|
||||
}
|
||||
const params: ConsentParams = {
|
||||
client_id: q.client_id ?? "",
|
||||
redirect_uri: q.redirect_uri,
|
||||
state: q.state ?? "",
|
||||
code_challenge: q.code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
scope: q.scope ?? "mcp",
|
||||
resource: canonicalResource, // 一律存 canonical,不存 client 原樣值
|
||||
};
|
||||
return c.html(consentPage(params));
|
||||
});
|
||||
|
||||
// ── POST /authorize:驗 owner 祕密 → 發 authorization code → redirect ───────────
|
||||
app.post("/authorize", async (c) => {
|
||||
const p = await readParams(c.req.raw);
|
||||
const redirectUri = p.redirect_uri ?? "";
|
||||
// 再驗一次 redirect_uri(POST 的欄位是隱藏帶回來的,仍須擋竄改)。
|
||||
if (!redirectUri || !isAllowedRedirect(redirectUri, c.env)) {
|
||||
return c.text("invalid_request: redirect_uri not allowed", 400);
|
||||
}
|
||||
if (p.code_challenge_method !== "S256" || !p.code_challenge) {
|
||||
return c.text("invalid_request: PKCE S256 required", 400);
|
||||
}
|
||||
const canonicalResource = resourceUri(originOf(c.req.url));
|
||||
// RFC 8707:resource(隱藏欄位帶回,仍可能被竄改)→ 正規化後須 == canonical,否則 redirect 帶 invalid_target。
|
||||
if (p.resource && !resourceMatches(p.resource, originOf(c.req.url))) {
|
||||
return c.redirect(
|
||||
redirectWith(redirectUri, {
|
||||
error: "invalid_target",
|
||||
error_description: "resource does not match this MCP server",
|
||||
...(p.state ? { state: p.state } : {}),
|
||||
}),
|
||||
302,
|
||||
);
|
||||
}
|
||||
if (!c.env.MCP_OWNER_SECRET) {
|
||||
return c.text("server_error: MCP_OWNER_SECRET not configured", 503);
|
||||
}
|
||||
const consent: ConsentParams = {
|
||||
client_id: p.client_id ?? "",
|
||||
redirect_uri: redirectUri,
|
||||
state: p.state ?? "",
|
||||
code_challenge: p.code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
scope: p.scope ?? "mcp",
|
||||
resource: canonicalResource, // 一律存 canonical,不存 client 原樣值
|
||||
};
|
||||
// ★ owner 祕密把關:錯誤不發碼、重顯同意頁。這是「只知 URL 的人進不來」的唯一閘。
|
||||
const supplied = p.owner_secret ?? "";
|
||||
if (!supplied || !constantTimeEqual(supplied, c.env.MCP_OWNER_SECRET)) {
|
||||
return c.html(consentPage(consent, "Owner 祕密不正確,請重試。"), 401);
|
||||
}
|
||||
if (!c.env.OAUTH_KV) {
|
||||
return c.text("server_error: OAUTH_KV not configured", 503);
|
||||
}
|
||||
const code = randomToken(32);
|
||||
await putAuthCode(c.env.OAUTH_KV, code, {
|
||||
client_id: consent.client_id,
|
||||
redirect_uri: redirectUri,
|
||||
code_challenge: consent.code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
scope: consent.scope,
|
||||
resource: consent.resource,
|
||||
namespace: ownerNamespace(c.env),
|
||||
});
|
||||
const location = redirectWith(redirectUri, {
|
||||
code,
|
||||
...(consent.state ? { state: consent.state } : {}),
|
||||
});
|
||||
return c.redirect(location, 302);
|
||||
});
|
||||
|
||||
// ── POST /token:code + PKCE verifier → access_token ────────────────────────────
|
||||
app.post("/token", async (c) => {
|
||||
const p = await readParams(c.req.raw);
|
||||
const err = (code: string, desc: string, status = 400) =>
|
||||
c.json({ error: code, error_description: desc }, status as 400, CORS_JSON);
|
||||
|
||||
if (p.grant_type !== "authorization_code") {
|
||||
return err("unsupported_grant_type", "only authorization_code is supported");
|
||||
}
|
||||
if (!p.code || !p.code_verifier || !p.redirect_uri) {
|
||||
return err("invalid_request", "code, code_verifier and redirect_uri are required");
|
||||
}
|
||||
// RFC 8707:token request 帶 resource 就得正規化後 == canonical,否則簽發端拒(400 invalid_target)。
|
||||
if (p.resource && !resourceMatches(p.resource, originOf(c.req.url))) {
|
||||
return err("invalid_target", "resource does not match this MCP server");
|
||||
}
|
||||
if (!c.env.OAUTH_KV) {
|
||||
return err("server_error", "OAUTH_KV not configured", 503);
|
||||
}
|
||||
const data = await consumeAuthCode(c.env.OAUTH_KV, p.code); // 一次性
|
||||
if (!data) {
|
||||
return err("invalid_grant", "authorization code invalid or expired");
|
||||
}
|
||||
if (data.redirect_uri !== p.redirect_uri) {
|
||||
return err("invalid_grant", "redirect_uri mismatch");
|
||||
}
|
||||
if (p.client_id && data.client_id && p.client_id !== data.client_id) {
|
||||
return err("invalid_grant", "client_id mismatch");
|
||||
}
|
||||
const pkceOk = await verifyPkceS256(p.code_verifier, data.code_challenge, data.code_challenge_method);
|
||||
if (!pkceOk) {
|
||||
return err("invalid_grant", "PKCE verification failed");
|
||||
}
|
||||
|
||||
const ttl = tokenTtl(c.env);
|
||||
const accessToken = randomToken(32);
|
||||
await putAccessToken(
|
||||
c.env.OAUTH_KV,
|
||||
accessToken,
|
||||
{
|
||||
namespace: data.namespace,
|
||||
client_id: data.client_id,
|
||||
scope: data.scope,
|
||||
// RFC 8707:aud 一律用「本 server canonical resource URI」(非 client 原樣值)。
|
||||
// authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。
|
||||
aud: resourceUri(originOf(c.req.url)),
|
||||
exp: Math.floor(Date.now() / 1000) + ttl,
|
||||
},
|
||||
ttl,
|
||||
);
|
||||
return c.json(
|
||||
{
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: ttl,
|
||||
scope: data.scope,
|
||||
},
|
||||
200,
|
||||
CORS_JSON,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export { AUTH_CODE_TTL_SECONDS };
|
||||
@@ -0,0 +1,78 @@
|
||||
// OAuth 短效認證儲存(KV)。儲存鐵律(leo + wiki):
|
||||
// 只有「取得的暫時性認證」可進 KV,且帶 TTL——authorization code、access token 是也。
|
||||
// 長效機密(owner secret)走 CF Secrets,不進此處。DCR client 不落地(見 OAUTH.md 相容決策)。
|
||||
// KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。
|
||||
import { sha256Hex } from "./crypto.js";
|
||||
|
||||
/** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */
|
||||
export interface AuthCodeData {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
scope: string;
|
||||
/** RFC 8707 resource:綁定 token 的目標 MCP server(audience)。 */
|
||||
resource: string;
|
||||
/** 換發後 token 綁定的資料分區(owner namespace)。 */
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
/** access token 綁定的資料。 */
|
||||
export interface AccessTokenData {
|
||||
namespace: string;
|
||||
client_id: string;
|
||||
scope: string;
|
||||
/** token 的目標受眾(= 本 MCP server 的 canonical URI),驗證時比對。 */
|
||||
aud: string;
|
||||
/** 過期時間(epoch 秒),與 KV TTL 雙保險。 */
|
||||
exp: number;
|
||||
}
|
||||
|
||||
const CODE_PREFIX = "oauth:code:";
|
||||
const TOKEN_PREFIX = "oauth:tok:";
|
||||
/** authorization code 存活秒數(一次性、極短效)。 */
|
||||
export const AUTH_CODE_TTL_SECONDS = 600;
|
||||
|
||||
/** 存 authorization code(TTL 極短)。回傳 raw code 給 client。 */
|
||||
export async function putAuthCode(kv: KVNamespace, code: string, data: AuthCodeData): Promise<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.1:code 用過必失效)
|
||||
try {
|
||||
return JSON.parse(raw) as AuthCodeData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 存 access token(TTL = ttlSeconds)。回傳 raw token 給 client。 */
|
||||
export async function putAccessToken(
|
||||
kv: KVNamespace,
|
||||
token: string,
|
||||
data: AccessTokenData,
|
||||
ttlSeconds: number,
|
||||
): Promise<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;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,29 @@ export interface Env {
|
||||
// 未設 / "true" = 官方 SaaS:維持 partner-key 驗證(行為完全不變)。
|
||||
// SDD: sdk-and-website/mcp-account-source.md;HANDOFF §3b。
|
||||
MULTI_TENANT?: string;
|
||||
|
||||
// ── OAuth 2.1 server(claude.ai 遠端 connector 安全登入,見 mcp/OAUTH.md)──────────
|
||||
// 短效認證儲存:authorization code(TTL ~600s)+ access token(TTL = MCP_TOKEN_TTL)。
|
||||
// 只放「取得的暫時性認證」,key 用 SHA-256 hash(KV list 不外洩可用 token)。長效機密不進 KV。
|
||||
OAUTH_KV?: KVNamespace;
|
||||
// Owner 祕密(CF Secret,非 KV、非明碼 var):/authorize 同意頁的把關密碼。
|
||||
// 只有 owner 知道 → 「只知 URL + 明碼 namespace」的人走不完 OAuth,拿不到 token。
|
||||
// 未設 → OAuth /authorize 回 503(拒絕在無把關下發碼,不留不安全預設)。
|
||||
MCP_OWNER_SECRET?: string;
|
||||
// OAuth 換發出的 access_token 綁定的 namespace(owner 的資料分區)。預設 "leo"。
|
||||
MCP_OWNER_NAMESPACE?: string;
|
||||
// access_token 存活秒數(同時是 KV TTL)。字串(toml var)。預設 2592000(30 天)。
|
||||
// 過期後 claude.ai 重走 OAuth(owner 重輸祕密)——刻意不做 refresh token 以免長效機密落地。
|
||||
MCP_TOKEN_TTL?: string;
|
||||
// 本機 CLI / GUI / 本機 Claude Code 的相容用「真祕密 token」(CF Secret)。
|
||||
// 設了才啟用:Bearer 精確等於此值 → 解成 MCP_OWNER_NAMESPACE。取代舊「明碼 namespace 當 bearer」漏洞路徑。
|
||||
MCP_STATIC_TOKEN?: string;
|
||||
// 【不安全】相容逃生門:="true" 時恢復舊「Bearer 明碼即 namespace」行為(self-hosted)。
|
||||
// 預設 undefined = 關(安全)。僅供遷移期,設了等於重新打開被修掉的漏洞——別在正式環境開。
|
||||
ALLOW_PLAINTEXT_NAMESPACE?: string;
|
||||
// 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。
|
||||
// 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。
|
||||
MCP_ALLOWED_REDIRECT_HOSTS?: string;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// PKCE:verifier "1234...43+ chars" → 直接算 challenge。
|
||||
async function pkcePair() {
|
||||
const verifier = "a".repeat(64);
|
||||
const challenge = await sha256Base64Url(verifier);
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function baseEnv(over: Partial<Env> = {}): Env {
|
||||
return {
|
||||
COMPONENT_REGISTRY: {} as Fetcher,
|
||||
CYPHER_EXECUTOR: {} as Fetcher,
|
||||
KBDB: {} as Fetcher,
|
||||
KBDB_INTERNAL_TOKEN: "internal",
|
||||
OAUTH_KV: makeKV(),
|
||||
MCP_OWNER_SECRET: "s3cr3t-owner",
|
||||
MCP_OWNER_NAMESPACE: "leo",
|
||||
...over,
|
||||
} as Env;
|
||||
}
|
||||
|
||||
// ── crypto ──────────────────────────────────────────────────────────────────────
|
||||
describe("oauth/crypto", () => {
|
||||
it("randomToken 長度足夠且每次不同", () => {
|
||||
const a = randomToken();
|
||||
const b = randomToken();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.length).toBeGreaterThanOrEqual(40);
|
||||
expect(a).toMatch(/^[A-Za-z0-9\-_]+$/); // base64url 無 padding
|
||||
});
|
||||
|
||||
it("sha256Hex/base64url 為已知值", async () => {
|
||||
// echo -n "abc" | sha256sum → ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
|
||||
expect(await sha256Hex("abc")).toBe(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
);
|
||||
// RFC 7636 附錄範例:verifier → challenge
|
||||
expect(await sha256Base64Url("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")).toBe(
|
||||
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
);
|
||||
});
|
||||
|
||||
it("constantTimeEqual 正確", () => {
|
||||
expect(constantTimeEqual("abc", "abc")).toBe(true);
|
||||
expect(constantTimeEqual("abc", "abd")).toBe(false);
|
||||
expect(constantTimeEqual("abc", "abcd")).toBe(false);
|
||||
});
|
||||
|
||||
it("verifyPkceS256:S256 正確、拒 plain/短 verifier/竄改", async () => {
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
expect(await verifyPkceS256(verifier, challenge, "S256")).toBe(true);
|
||||
expect(await verifyPkceS256(verifier, challenge, "plain")).toBe(false);
|
||||
expect(await verifyPkceS256(verifier, challenge, undefined)).toBe(false);
|
||||
expect(await verifyPkceS256("short", challenge, "S256")).toBe(false);
|
||||
expect(await verifyPkceS256("b".repeat(64), challenge, "S256")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── store(短效 KV)────────────────────────────────────────────────────────────
|
||||
describe("oauth/store", () => {
|
||||
it("authorization code 一次性:consume 後即失效(防重放)", async () => {
|
||||
const kv = makeKV();
|
||||
await putAuthCode(kv, "code-1", {
|
||||
client_id: "c1",
|
||||
redirect_uri: "https://claude.ai/cb",
|
||||
code_challenge: "cc",
|
||||
code_challenge_method: "S256",
|
||||
scope: "mcp",
|
||||
resource: "https://mcp/mcp",
|
||||
namespace: "leo",
|
||||
});
|
||||
const first = await consumeAuthCode(kv, "code-1");
|
||||
expect(first?.namespace).toBe("leo");
|
||||
const second = await consumeAuthCode(kv, "code-1");
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
|
||||
it("access token 存取 + exp 過期回 null", async () => {
|
||||
const kv = makeKV();
|
||||
await putAccessToken(
|
||||
kv,
|
||||
"tok-1",
|
||||
{ namespace: "leo", client_id: "c1", scope: "mcp", aud: "https://mcp/mcp", exp: Math.floor(Date.now() / 1000) + 100 },
|
||||
100,
|
||||
);
|
||||
expect((await getAccessToken(kv, "tok-1"))?.namespace).toBe("leo");
|
||||
// 已過期
|
||||
await putAccessToken(
|
||||
kv,
|
||||
"tok-2",
|
||||
{ namespace: "leo", client_id: "c1", scope: "mcp", aud: "x", exp: Math.floor(Date.now() / 1000) - 10 },
|
||||
100,
|
||||
);
|
||||
expect(await getAccessToken(kv, "tok-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("KV key 為 hash(不把 raw token 當 key)", async () => {
|
||||
// 白盒:store 內部用 sha256Hex,這裡驗 hash 與 raw 不同即可
|
||||
expect(await sha256Hex("tok-1")).not.toContain("tok-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── metadata ──────────────────────────────────────────────────────────────────
|
||||
describe("oauth/metadata", () => {
|
||||
it("originOf / resourceUri", () => {
|
||||
expect(originOf("https://Mcp.Arcrun.dev/mcp")).toBe("https://mcp.arcrun.dev");
|
||||
expect(resourceUri("https://mcp.arcrun.dev")).toBe("https://mcp.arcrun.dev/mcp");
|
||||
});
|
||||
it("protectedResourceMetadata 必要欄位", () => {
|
||||
const m = protectedResourceMetadata("https://mcp.arcrun.dev");
|
||||
expect(m.resource).toBe("https://mcp.arcrun.dev/mcp");
|
||||
expect(m.authorization_servers).toEqual(["https://mcp.arcrun.dev"]);
|
||||
});
|
||||
it("authorizationServerMetadata 必要欄位(S256/code/none)", () => {
|
||||
const m = authorizationServerMetadata("https://mcp.arcrun.dev");
|
||||
expect(m.authorization_endpoint).toBe("https://mcp.arcrun.dev/authorize");
|
||||
expect(m.token_endpoint).toBe("https://mcp.arcrun.dev/token");
|
||||
expect(m.registration_endpoint).toBe("https://mcp.arcrun.dev/register");
|
||||
expect(m.response_types_supported).toEqual(["code"]);
|
||||
expect(m.code_challenge_methods_supported).toEqual(["S256"]);
|
||||
expect(m.token_endpoint_auth_methods_supported).toEqual(["none"]);
|
||||
});
|
||||
it("wwwAuthenticateHeader 指向 protected-resource metadata", () => {
|
||||
expect(wwwAuthenticateHeader("https://mcp.arcrun.dev")).toBe(
|
||||
'Bearer resource_metadata="https://mcp.arcrun.dev/.well-known/oauth-protected-resource"',
|
||||
);
|
||||
});
|
||||
it("normalizeResource:尾斜線 / 大小寫 scheme+host / 預設 port 都正規化成 canonical", () => {
|
||||
const canon = "https://mcp.arcrun.dev/mcp";
|
||||
expect(normalizeResource("https://mcp.arcrun.dev/mcp")).toBe(canon);
|
||||
expect(normalizeResource("https://mcp.arcrun.dev/mcp/")).toBe(canon); // 尾斜線
|
||||
expect(normalizeResource("HTTPS://Mcp.Arcrun.Dev/mcp")).toBe(canon); // 大小寫 scheme+host
|
||||
expect(normalizeResource("https://mcp.arcrun.dev:443/mcp")).toBe(canon); // 預設 port
|
||||
expect(normalizeResource("not a url")).toBeNull();
|
||||
});
|
||||
it("resourceMatches:canonical / 尾斜線變體都 true;別的 host/path false", () => {
|
||||
const origin = "https://mcp.arcrun.dev";
|
||||
expect(resourceMatches("https://mcp.arcrun.dev/mcp", origin)).toBe(true);
|
||||
expect(resourceMatches("https://mcp.arcrun.dev/mcp/", origin)).toBe(true);
|
||||
expect(resourceMatches("https://other.example.com/mcp", origin)).toBe(false);
|
||||
expect(resourceMatches("https://mcp.arcrun.dev/other", origin)).toBe(false);
|
||||
expect(resourceMatches("garbage", origin)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── consent escaping(XSS)────────────────────────────────────────────────────
|
||||
describe("oauth/consent", () => {
|
||||
it("esc 跳脫 HTML 特殊字元", () => {
|
||||
expect(esc(`<script>"&'`)).toBe("<script>"&'");
|
||||
});
|
||||
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("<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_id(public 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 /authorize:MCP_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 resource(RFC 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();
|
||||
// 先正常拿一個 code(authorize 不帶 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 guard:OAUTH_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(),
|
||||
});
|
||||
|
||||
// 至少發生了 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.5:self-hosted(MULTI_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-hosted:Bearer 明碼即 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 的最小 app,protected 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-Authenticate(RFC 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 URI(app.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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,29 @@ service = "arcrun-cypher-executor"
|
||||
binding = "KBDB"
|
||||
service = "arcrun-kbdb"
|
||||
|
||||
# ── OAuth 2.1 server(claude.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 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" # CLI 部署會自動換;手動直推才需填(wrangler kv namespace create OAUTH_MCP)
|
||||
|
||||
# ── OAuth 相關機密與設定 ───────────────────────────────────────────────────────────
|
||||
# 機密走 CF Secrets(wrangler 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 存活秒數,預設 2592000(30 天,有意取捨見 OAUTH.md §5;7 天為更保守選項)
|
||||
# MCP_ALLOWED_REDIRECT_HOSTS 逗號分隔 redirect host 白名單,預設 claude.ai/claude.com/anthropic.com+localhost
|
||||
# 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 失敗。
|
||||
# 對外正式 MCP URL = mcp.arcrun.dev;對內 / fork 仍可走 workers_dev=true 的 arcrun-mcp.<sub>.workers.dev。
|
||||
|
||||
Reference in New Issue
Block a user