fix(mcp): validate/normalize RFC 8707 resource at issuance; store canonical aud
leo review 最後一條:簽發端沒驗證/正規化 resource → 失敗劇本「OAuth 全程成功但每次打 /mcp 401 aud mismatch」(尾斜線/canonical 變體,錯誤離根因最遠最難 debug)。改在簽發端 fail fast: - metadata.ts 加 normalizeResource(scheme/host 小寫、去預設 port、path 去尾斜線,與 resourceUri canonical 一致)+ resourceMatches。 - /authorize(GET+POST):帶 resource 且正規化後 != canonical → redirect 帶 error=invalid_target (redirect_uri 已驗過才 redirect);一律把 canonical resource 存進 code(不存 client 原樣值)。 - /token:帶 resource 且正規化後 != canonical → 400 invalid_target;aud 一律存 canonical resourceUri(origin) → 與 partner-auth 嚴格比對 at.aud===resourceUri(origin) 恆一致。 裁決:尾斜線/大小寫等「正規化後等價」的 resource → 接受(存 canonical aud,/mcp 必過),非拒絕—— 否則 claude.ai 真送變體會永久授權失敗連不上(把 401 問題換位重現)。只有正規化後真正不同的 resource(別 host/path)才 fail-fast 拒。詳見 OAUTH.md §2。 測試:normalizeResource/resourceMatches 單元 + 尾斜線變體→正常發碼且 aud canonical、別 host→ /authorize redirect invalid_target 不發碼、/token 別 host→400 invalid_target。 mcp vitest 52/52、tsc exit 0。 Refs #15 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
This commit is contained in:
@@ -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 {
|
||||
|
||||
+35
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user