Files
Arcrun/mcp/src/oauth/routes.ts
T
Claude 5fa3b79a4c 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
2026-07-07 05:38:35 +00:00

326 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// OAuth 2.1 + PKCE server 路由。掛在 worker 根路徑(非 /mcp basePath),
// 因為 well-known / authorize / token / register 都得在 origin 根,claude.ai 才發現得到。
// 安全模型與相容決策見 mcp/OAUTH.md。
import { Hono } from "hono";
import type { Env as HonoBaseEnv } from "hono";
import { Env } from "../types.js";
import {
randomToken,
verifyPkceS256,
constantTimeEqual,
} from "./crypto.js";
import {
putAuthCode,
consumeAuthCode,
putAccessToken,
AUTH_CODE_TTL_SECONDS,
} from "./store.js";
import {
originOf,
resourceUri,
resourceMatches,
protectedResourceMetadata,
authorizationServerMetadata,
} from "./metadata.js";
import { consentPage, ConsentParams } from "./consent.js";
const DEFAULT_TOKEN_TTL = 2592000; // 30 天
const DEFAULT_REDIRECT_HOSTS = ["claude.ai", "claude.com", "anthropic.com"];
const CORS_JSON = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Cache-Control": "no-store",
} as const;
function ownerNamespace(env: Env): string {
return env.MCP_OWNER_NAMESPACE || "leo";
}
function tokenTtl(env: Env): number {
const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL;
}
/** redirect_uri 白名單檢查(OAuth 2.1http 只准 localhost,其餘須 https 且 host 在白名單)。 */
function isAllowedRedirect(uri: string, env: Env): boolean {
let u: URL;
try {
u = new URL(uri);
} catch {
return false;
}
const host = u.hostname.toLowerCase();
const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
if (u.protocol === "http:") return isLocal; // http 僅限本機
if (u.protocol !== "https:") return false;
if (isLocal) return true;
const configured = (env.MCP_ALLOWED_REDIRECT_HOSTS ?? "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
const list = configured.length ? configured : DEFAULT_REDIRECT_HOSTS;
return list.some((h) => host === h || host.endsWith("." + h));
}
/** 從 form 或 JSON body 讀參數(token/register 端點的 content-type 兩種都容忍)。 */
async function readParams(req: Request): Promise<Record<string, string>> {
const ct = req.headers.get("content-type") ?? "";
try {
if (ct.includes("application/json")) {
const j = (await req.json()) as Record<string, unknown>;
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(j)) if (typeof v === "string") out[k] = v;
return out;
}
const form = await req.formData();
const out: Record<string, string> = {};
for (const [k, v] of form.entries()) if (typeof v === "string") out[k] = v;
return out;
} catch {
return {};
}
}
/** 組 redirect 回 client 的 URL(把 query 併進 redirect_uri)。 */
function redirectWith(redirectUri: string, params: Record<string, string>): string {
const u = new URL(redirectUri);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
return u.toString();
}
/**
* 掛載 OAuth 路由到根 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 一律 JSONRFC 7591)。單次解析取 redirect_uris(陣列)+ client_name。
let raw: { redirect_uris?: unknown; client_name?: unknown } = {};
try {
raw = (await c.req.raw.json()) as typeof raw;
} catch {
/* 無 / 非 JSON body:容忍,redirect_uris 視為空 */
}
const redirectUris: string[] = Array.isArray(raw.redirect_uris)
? raw.redirect_uris.filter((x): x is string => typeof x === "string")
: [];
const clientName = typeof raw.client_name === "string" ? raw.client_name : "MCP Client";
for (const uri of redirectUris) {
if (!isAllowedRedirect(uri, c.env)) {
return c.json(
{ error: "invalid_redirect_uri", error_description: `redirect_uri not allowed: ${uri}` },
400,
CORS_JSON,
);
}
}
const clientId = "mcp_" + randomToken(16);
return c.json(
{
client_id: clientId,
client_id_issued_at: Math.floor(Date.now() / 1000),
redirect_uris: redirectUris,
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
client_name: clientName,
},
201,
CORS_JSON,
);
});
// ── GET /authorize:呈現 owner 祕密同意頁 ───────────────────────────────────────
app.get("/authorize", (c) => {
const q = c.req.query();
if (q.response_type !== "code") {
return c.text("unsupported_response_type: only 'code' is supported", 400);
}
if (q.code_challenge_method !== "S256" || !q.code_challenge) {
return c.text("invalid_request: PKCE S256 code_challenge required", 400);
}
if (!q.redirect_uri || !isAllowedRedirect(q.redirect_uri, c.env)) {
// redirect_uri 本身可疑 → 絕不 redirect(防 open redirect),直接顯示錯誤。
return c.text("invalid_request: redirect_uri missing or not allowed", 400);
}
const canonicalResource = resourceUri(originOf(c.req.url));
// RFC 8707client 傳了 resource 就必須正規化後 == 本 server canonical,否則簽發端 fail fast。
// redirect_uri 已驗過 → 用 OAuth 錯誤 redirect 帶 error=invalid_target(比顯示 400 更符規範)。
if (q.resource && !resourceMatches(q.resource, originOf(c.req.url))) {
return c.redirect(
redirectWith(q.redirect_uri, {
error: "invalid_target",
error_description: "resource does not match this MCP server",
...(q.state ? { state: q.state } : {}),
}),
302,
);
}
if (!c.env.MCP_OWNER_SECRET) {
return c.text("server_error: MCP_OWNER_SECRET not configured", 503);
}
const params: ConsentParams = {
client_id: q.client_id ?? "",
redirect_uri: q.redirect_uri,
state: q.state ?? "",
code_challenge: q.code_challenge,
code_challenge_method: "S256",
scope: q.scope ?? "mcp",
resource: canonicalResource, // 一律存 canonical,不存 client 原樣值
};
return c.html(consentPage(params));
});
// ── POST /authorize:驗 owner 祕密 → 發 authorization code → redirect ───────────
app.post("/authorize", async (c) => {
const p = await readParams(c.req.raw);
const redirectUri = p.redirect_uri ?? "";
// 再驗一次 redirect_uri(POST 的欄位是隱藏帶回來的,仍須擋竄改)。
if (!redirectUri || !isAllowedRedirect(redirectUri, c.env)) {
return c.text("invalid_request: redirect_uri not allowed", 400);
}
if (p.code_challenge_method !== "S256" || !p.code_challenge) {
return c.text("invalid_request: PKCE S256 required", 400);
}
const canonicalResource = resourceUri(originOf(c.req.url));
// RFC 8707resource(隱藏欄位帶回,仍可能被竄改)→ 正規化後須 == canonical,否則 redirect 帶 invalid_target。
if (p.resource && !resourceMatches(p.resource, originOf(c.req.url))) {
return c.redirect(
redirectWith(redirectUri, {
error: "invalid_target",
error_description: "resource does not match this MCP server",
...(p.state ? { state: p.state } : {}),
}),
302,
);
}
if (!c.env.MCP_OWNER_SECRET) {
return c.text("server_error: MCP_OWNER_SECRET not configured", 503);
}
const consent: ConsentParams = {
client_id: p.client_id ?? "",
redirect_uri: redirectUri,
state: p.state ?? "",
code_challenge: p.code_challenge,
code_challenge_method: "S256",
scope: p.scope ?? "mcp",
resource: canonicalResource, // 一律存 canonical,不存 client 原樣值
};
// ★ owner 祕密把關:錯誤不發碼、重顯同意頁。這是「只知 URL 的人進不來」的唯一閘。
const supplied = p.owner_secret ?? "";
if (!supplied || !constantTimeEqual(supplied, c.env.MCP_OWNER_SECRET)) {
return c.html(consentPage(consent, "Owner 祕密不正確,請重試。"), 401);
}
if (!c.env.OAUTH_KV) {
return c.text("server_error: OAUTH_KV not configured", 503);
}
const code = randomToken(32);
await putAuthCode(c.env.OAUTH_KV, code, {
client_id: consent.client_id,
redirect_uri: redirectUri,
code_challenge: consent.code_challenge,
code_challenge_method: "S256",
scope: consent.scope,
resource: consent.resource,
namespace: ownerNamespace(c.env),
});
const location = redirectWith(redirectUri, {
code,
...(consent.state ? { state: consent.state } : {}),
});
return c.redirect(location, 302);
});
// ── POST /tokencode + PKCE verifier → access_token ────────────────────────────
app.post("/token", async (c) => {
const p = await readParams(c.req.raw);
const err = (code: string, desc: string, status = 400) =>
c.json({ error: code, error_description: desc }, status as 400, CORS_JSON);
if (p.grant_type !== "authorization_code") {
return err("unsupported_grant_type", "only authorization_code is supported");
}
if (!p.code || !p.code_verifier || !p.redirect_uri) {
return err("invalid_request", "code, code_verifier and redirect_uri are required");
}
// RFC 8707token request 帶 resource 就得正規化後 == canonical,否則簽發端拒(400 invalid_target)。
if (p.resource && !resourceMatches(p.resource, originOf(c.req.url))) {
return err("invalid_target", "resource does not match this MCP server");
}
if (!c.env.OAUTH_KV) {
return err("server_error", "OAUTH_KV not configured", 503);
}
const data = await consumeAuthCode(c.env.OAUTH_KV, p.code); // 一次性
if (!data) {
return err("invalid_grant", "authorization code invalid or expired");
}
if (data.redirect_uri !== p.redirect_uri) {
return err("invalid_grant", "redirect_uri mismatch");
}
if (p.client_id && data.client_id && p.client_id !== data.client_id) {
return err("invalid_grant", "client_id mismatch");
}
const pkceOk = await verifyPkceS256(p.code_verifier, data.code_challenge, data.code_challenge_method);
if (!pkceOk) {
return err("invalid_grant", "PKCE verification failed");
}
const ttl = tokenTtl(c.env);
const accessToken = randomToken(32);
await putAccessToken(
c.env.OAUTH_KV,
accessToken,
{
namespace: data.namespace,
client_id: data.client_id,
scope: data.scope,
// RFC 8707aud 一律用「本 server canonical resource URI」(非 client 原樣值)。
// authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。
aud: resourceUri(originOf(c.req.url)),
exp: Math.floor(Date.now() / 1000) + ttl,
},
ttl,
);
return c.json(
{
access_token: accessToken,
token_type: "Bearer",
expires_in: ttl,
scope: data.scope,
},
200,
CORS_JSON,
);
});
}
export { AUTH_CODE_TTL_SECONDS };