Files
Arcrun/mcp/src/oauth/routes.ts
T
Leo b8ca98cb49 MCP 認證改用 Portal 帳密,廢掉 MCP_OWNER_SECRET(leo 指定做法)
leo:「claude 裡有一個直接輸入帳密連線的,為什麼不用那個?跟他輸入 portal 的帳密一樣不就好了?」

為什麼換(三個封測撞出來的實際問題):
① 沒人給得了封測者——安裝器產生後從不顯示(完成頁 grep「Owner 祕密」=0),
   CF secret 又唯寫讀不回 ⇒ 用戶卡在同意頁,只能找 leo 手動 wrangler 覆寫
② 多一把要記的金鑰——違反「拿一把金鑰就很難了」
③ 全實例共用一把,無法分辨誰連上來(企業多人版必要)

風險評估(leo 判斷,總管原本誇大成「繞過帳密的旁路」已認錯):
secret 要貼進 claude.ai(本身有帳密保護)⇒ 洩漏 secret 與洩漏 portal 帳密風險相同。

改動:
- consent.ts:一個「Owner 祕密」欄位 → email + password 兩欄
- routes.ts POST /authorize:constantTimeEqual(MCP_OWNER_SECRET)
  → 走 CYPHER_EXECUTOR binding 打 /portal/login(認證下沉到唯一真相源,
    同樣吃它的節流與停用檢查)
- routes.ts GET /authorize:移除「未設 MCP_OWNER_SECRET → 503」
  (那是「每個封測者都死在這頁」的直接原因)

驗:tsc 零錯誤;已部署 youlin。
2026-07-31 00:44:15 +08:00

351 lines
15 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,
);
}
// 2026-07-30:不再檢查 MCP_OWNER_SECRET(改用 Portal 帳密驗證,見 POST 分支)。
// 舊行為:未設此 env → 直接 503 ⇒ **每個封測者接自己的 AI 都死在這頁**。
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,
);
}
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 原樣值
};
// ★ 把關:用**用戶自己的 Portal 帳密**,不再另設一把 MCP_OWNER_SECRET
// leo 2026-07-30:「claude 裡有一個直接輸入帳密連線的,為什麼不用那個?
// 跟他輸入 portal 的帳密一樣不就好了?」)
//
// 為什麼換掉 owner secret(三個實際問題,都是封測撞出來的):
// ① **沒人給得了封測者**——安裝器產生後從不顯示(完成頁 grep「Owner 祕密」=0),
// CF secret 又唯寫讀不回 ⇒ 用戶卡在這頁,只能找 leo 手動 wrangler 覆寫
// ② **多一把要記的金鑰**——違反「拿一把金鑰就很難了」(D36 精神)
// ③ **全實例共用一把**,無法分辨是誰連上來的(企業多人版必要)
// 風險評估(leo 判斷,總管原本誇大成「繞過帳密的旁路」已更正):
// secret 要貼進 claude.ai(本身有帳密保護)⇒ 洩漏 secret 與洩漏 portal 帳密風險相同。
const email = (p.email ?? "").trim();
const password = p.password ?? "";
if (!email || !password) {
return c.html(consentPage(consent, "請輸入你的 Portal 帳號與密碼。"), 401);
}
// 認證下沉到 cypher 的 /portal/login(唯一真相源;同樣吃它的節流與停用檢查)。
// 走 service bindingMCP 與 cypher 同帳號,屬 D28 允許的零件級組合)。
let loginOk = false;
try {
const res = await c.env.CYPHER_EXECUTOR.fetch(
new Request("https://cypher/portal/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
}),
);
loginOk = res.ok;
} catch {
return c.html(consentPage(consent, "暫時無法驗證帳密,請稍後再試。"), 503);
}
if (!loginOk) {
return c.html(consentPage(consent, "帳號或密碼不正確,請重試。"), 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 };