diff --git a/mcp/OAUTH.md b/mcp/OAUTH.md index cd1ea0e..de15f98 100644 --- a/mcp/OAUTH.md +++ b/mcp/OAUTH.md @@ -32,7 +32,16 @@ PKCE,但**走到發碼那步需要 owner secret**,而 secret 只在 CF Secre - **redirect_uri 白名單**(DCR 無狀態,見 §5):預設只允許 `claude.ai`/`claude.com`/`anthropic.com` (含子網域)+ `localhost`;`http` 僅限本機。擋 open-redirect/釣魚把 code 送去攻擊者。可用 `MCP_ALLOWED_REDIRECT_HOSTS` 調整。 -- **token audience 綁定(RFC 8707)**:code/token 記 `resource`/`aud` = 本 MCP server 的 canonical URI。 +- **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。 @@ -107,6 +116,10 @@ metadata 以「當前請求 origin」動態生成 → 同一份碼在 `mcp.arcru - 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→ diff --git a/mcp/src/oauth/metadata.ts b/mcp/src/oauth/metadata.ts index 2ab2cee..e61e488 100644 --- a/mcp/src/oauth/metadata.ts +++ b/mcp/src/oauth/metadata.ts @@ -12,6 +12,38 @@ 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 { diff --git a/mcp/src/oauth/routes.ts b/mcp/src/oauth/routes.ts index 536f16f..2dea19c 100644 --- a/mcp/src/oauth/routes.ts +++ b/mcp/src/oauth/routes.ts @@ -18,6 +18,7 @@ import { import { originOf, resourceUri, + resourceMatches, protectedResourceMetadata, authorizationServerMetadata, } from "./metadata.js"; @@ -170,6 +171,19 @@ export function registerOAuthRoutes< // 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); } @@ -180,7 +194,7 @@ export function registerOAuthRoutes< code_challenge: q.code_challenge, code_challenge_method: "S256", scope: q.scope ?? "mcp", - resource: q.resource ?? resourceUri(originOf(c.req.url)), + resource: canonicalResource, // 一律存 canonical,不存 client 原樣值 }; return c.html(consentPage(params)); }); @@ -196,6 +210,18 @@ export function registerOAuthRoutes< 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); } @@ -206,7 +232,7 @@ export function registerOAuthRoutes< code_challenge: p.code_challenge, code_challenge_method: "S256", scope: p.scope ?? "mcp", - resource: p.resource ?? resourceUri(originOf(c.req.url)), + resource: canonicalResource, // 一律存 canonical,不存 client 原樣值 }; // ★ owner 祕密把關:錯誤不發碼、重顯同意頁。這是「只知 URL 的人進不來」的唯一閘。 const supplied = p.owner_secret ?? ""; @@ -245,6 +271,10 @@ export function registerOAuthRoutes< 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); } @@ -272,7 +302,9 @@ export function registerOAuthRoutes< namespace: data.namespace, client_id: data.client_id, scope: data.scope, - aud: data.resource, // RFC 8707:綁定受眾 = 本 MCP server + // 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, diff --git a/mcp/tests/unit/oauth.test.ts b/mcp/tests/unit/oauth.test.ts index cb43565..c8d0c3f 100644 --- a/mcp/tests/unit/oauth.test.ts +++ b/mcp/tests/unit/oauth.test.ts @@ -16,6 +16,8 @@ import { import { originOf, resourceUri, + normalizeResource, + resourceMatches, protectedResourceMetadata, authorizationServerMetadata, wwwAuthenticateHeader, @@ -176,6 +178,22 @@ describe("oauth/metadata", () => { '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)──────────────────────────────────────────────────── @@ -409,6 +427,112 @@ describe("oauth flow (整合)", () => { }); }); +// ── 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, 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 以外的東西)。