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:
@@ -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<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 以外的東西)。
|
||||
|
||||
Reference in New Issue
Block a user