Files
Arcrun/mcp/src/oauth/routes.ts
T
uncle6me-web 10d150ac2b fix(mcp): MCP 用登入者的身分查詢,不再去找一把服務內部金鑰
leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。

病根(不是金鑰沒同步,是身分沒接住):
  oauth/routes.ts 驗完 Portal 帳密只留下 `loginOk = res.ok` 一個布林值,身分當場丟棄,
  namespace 改從 `MCP_OWNER_NAMESPACE || "leo"` 拿。於是查詢時手上沒有身分可帶,
  只好用 KBDB_INTERNAL_TOKEN 直打 KBDB——那條路繞過 portal 所有庫過濾,
  而且不管誰登入都看到同一格、看到全部。CLI 也從不注入 MCP_OWNER_NAMESPACE,
  所以那個 "leo" 預設值是每台實例的實際行為,不是理論上的邊角。

修法(走既有那條路,不發明新的):
1. 接住身分:/authorize 解析 /portal/login 回應,把 portal session token +
   display_name/role/libraries 存進 authorization code → access token。
   /portal/login 補回 session_expires_in,access_token TTL 夾成
   min(自己的 TTL, portal session TTL)——不讓「MCP 還連著、底下 session 早死」。
   cypher 回 200 但沒給 session_token(舊版)→ 不發碼,不簽一張沒有身分的 token。
2. 攜帶身分:kbdb_* 全部改走 cypher `/portal/data/*`,Authorization 帶登入者的
   session。庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁同一道閘。
   kbdb_graph_neighbors 因此不再需要 kbdb_base(server 自己知道查哪個庫)。
   藏書地圖(含連線時注入 instructions 的那份)同樣只回有權限的庫,快取改 per-session
   分格——地圖本身就是情報,不能讓先連上的人把視野留給下一個。
3. fail-closed:舊 token 沒有身分 → 誠實要求重新連線,不偷偷退回服務金鑰那條老路。
   服務級憑據(static token / partner key)維持既有 KBDB 直連,arcrun_* 零回歸。

新增 cypher portal 資料面端點(能力長在 API,MCP 只暴露;rule 07):
  GET  /portal/data/map、/portal/data/map/:library
  GET  /portal/data/templates、POST /portal/data/templates
  GET  /portal/data/records/by-template/:t、GET /portal/data/records/:id
  POST /portal/data/records
全部:呼叫端自帶 owner_id 一律不生效;越權與不存在同回 404;寫入 owner_id 由 server 定死。

KBDB base:`GET /records/:id` 與 by-template 補回 owner_id 欄位——原本不回,
呼叫端無從判斷「這筆是不是我的」,按 id 直讀等於沒有租戶邊界。

沒動:KBDB fail-closed 閘、任何金鑰、租戶字串仍不下發給呼叫端。

驗證:
  mcp        tsc 綠;vitest 113/113 綠(改前 48 綠 29 紅)
  cypher     vitest 400 綠 / 14 紅,14 紅與 base commit a24f291 逐條相同(既有)
  kbdb       vitest 208 綠 / 5 紅,5 紅同為既有(migrations/*.sql 被 gitignore)
  端到端     ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:33:12 +08:00

401 lines
18 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,
type PortalIdentity,
} 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;
/**
* **工作流面**(arcrun_* 工具)的租戶代號。知識面(kbdb_*)已不再讀它——
* 那邊改成跟著登入者的 portal session 走(見 store.ts PortalIdentity)。
*
* 為什麼這裡還留著、而且還有預設值:cypher 的 workflow API 是用「租戶代號當 opaque key」
* X-Arcrun-API-Key)認的,不吃 portal session;要拆掉它得先在 cypher 開一組
* 吃 portal session 的 workflow 端點。那是下一步,不在本次範圍——
* 硬拆會把現在好好的 arcrun_* 弄壞。**誠實記在這裡,不假裝已經解決。**
*
* ⚠️ 預設值 "leo" 的**知識面**用法已消滅:它曾經是「不管誰登入都看到同一格」的根因
* namespace 直接當 KBDB 的 owner_id 用)。現在它只當工作流面的 API key。
*/
function workflowTenant(env: Env): string {
return env.MCP_OWNER_NAMESPACE || "leo";
}
/** portal session TTL 讀不到時的保守假設(秒):短的那邊贏,寧可早點要求重連。 */
const FALLBACK_PORTAL_SESSION_TTL = 604800; // 7 天(cypher portal.ts 的預設值)
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 允許的零件級組合)。
//
// 🔴 2026-08-12leo:「用登入能做的 mcp 就應該能做,結果要你去打 MCP 時自己找
// credential 問題很大」):這裡**接住登入回來的身分**,不再只留 `res.ok`。
// 舊版把身分丟掉 ⇒ 查詢時無身分可帶 ⇒ 只好去撈服務內部金鑰(KBDB_INTERNAL_TOKEN
// 直打 KBDB ⇒ 繞過所有庫過濾、而且不管誰登入都看到同一格。根因就在這幾行。
let portal: PortalIdentity | null = null;
let portalTtl = FALLBACK_PORTAL_SESSION_TTL;
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 }),
}),
);
if (res.ok) {
const body = (await res.json().catch(() => null)) as {
session_token?: unknown;
display_name?: unknown;
role?: unknown;
libraries?: unknown;
session_expires_in?: unknown;
} | null;
const session = typeof body?.session_token === "string" ? body.session_token : "";
if (session) {
portal = {
session,
display_name: typeof body?.display_name === "string" ? body.display_name : "",
role: typeof body?.role === "string" ? body.role : "user",
libraries: Array.isArray(body?.libraries)
? body.libraries.filter((x): x is string => typeof x === "string")
: [],
};
const ttl = Number(body?.session_expires_in);
if (Number.isFinite(ttl) && ttl > 0) portalTtl = ttl;
}
}
} catch {
return c.html(consentPage(consent, "暫時無法驗證帳密,請稍後再試。"), 503);
}
if (!portal) {
// 帳密不對,或這台 cypher 舊到還不回 session_token。兩者都不可以發碼——
// 發了也是一張沒有身分的 token,查什麼都得再找一次 credential,正是要修的病。
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: workflowTenant(c.env),
portal,
portal_session_expires_in: portalTtl,
});
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");
}
// token 活不過它底下的 portal session:否則第 8 天會出現「MCP 還連著、卻什麼都查不到」
// ——使用者看到的是壞掉,實際是身分過期。兩者一起到期,重連就是重新輸入帳密,一次搞定。
const ttl = Math.min(tokenTtl(c.env), data.portal_session_expires_in || FALLBACK_PORTAL_SESSION_TTL);
const accessToken = randomToken(32);
await putAccessToken(
c.env.OAUTH_KV,
accessToken,
{
namespace: data.namespace,
client_id: data.client_id,
scope: data.scope,
portal: data.portal,
// 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 };