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>
This commit is contained in:
+202
-12
@@ -59,14 +59,55 @@ async function pkcePair() {
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* cypher `/portal/login` 的假替身(2026-08-12 起 MCP 的把關就是這支——用使用者自己的
|
||||
* Portal 帳密,沒有另一把 owner secret)。帳密對 → 回 session_token + 身分欄位;不對 → 401。
|
||||
*/
|
||||
const GOOD_EMAIL = "leo@example.com";
|
||||
const GOOD_PASSWORD = "correct horse";
|
||||
|
||||
function cypherMock(
|
||||
over: {
|
||||
/** null = 登入成功但**不回** session_token(舊版 cypher);預設回 "sess-abc" */
|
||||
sessionToken?: string | null;
|
||||
displayName?: string;
|
||||
role?: string;
|
||||
libraries?: string[];
|
||||
sessionExpiresIn?: number;
|
||||
} = {},
|
||||
): { fetcher: Fetcher; calls: Array<{ email: string; password: string }> } {
|
||||
const calls: Array<{ email: string; password: string }> = [];
|
||||
const fetcher = {
|
||||
async fetch(req: Request) {
|
||||
const body = (await req.json()) as { email: string; password: string };
|
||||
calls.push(body);
|
||||
if (body.email !== GOOD_EMAIL || body.password !== GOOD_PASSWORD) {
|
||||
return new Response(JSON.stringify({ error: "email 或密碼錯誤" }), { status: 401 });
|
||||
}
|
||||
const sessionToken = over.sessionToken === undefined ? "sess-abc" : over.sessionToken;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
...(sessionToken ? { session_token: sessionToken } : {}),
|
||||
display_name: over.displayName ?? "Leo",
|
||||
role: over.role ?? "admin",
|
||||
libraries: over.libraries ?? ["*"],
|
||||
session_expires_in: over.sessionExpiresIn ?? 604800,
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
} as unknown as Fetcher;
|
||||
return { fetcher, calls };
|
||||
}
|
||||
|
||||
function baseEnv(over: Partial<Env> = {}): Env {
|
||||
return {
|
||||
COMPONENT_REGISTRY: {} as Fetcher,
|
||||
CYPHER_EXECUTOR: {} as Fetcher,
|
||||
CYPHER_EXECUTOR: cypherMock().fetcher,
|
||||
KBDB: {} as Fetcher,
|
||||
KBDB_INTERNAL_TOKEN: "internal",
|
||||
OAUTH_KV: makeKV(),
|
||||
MCP_OWNER_SECRET: "s3cr3t-owner",
|
||||
MCP_OWNER_NAMESPACE: "leo",
|
||||
...over,
|
||||
} as Env;
|
||||
@@ -121,6 +162,8 @@ describe("oauth/store", () => {
|
||||
scope: "mcp",
|
||||
resource: "https://mcp/mcp",
|
||||
namespace: "leo",
|
||||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
|
||||
portal_session_expires_in: 604800,
|
||||
});
|
||||
const first = await consumeAuthCode(kv, "code-1");
|
||||
expect(first?.namespace).toBe("leo");
|
||||
@@ -271,7 +314,11 @@ describe("oauth flow (整合)", () => {
|
||||
)}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`,
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await ok.text()).toContain("Owner 祕密");
|
||||
const consentHtml = await ok.text();
|
||||
// 同意頁問的是 Portal 帳密(不是另一把 owner secret)
|
||||
expect(consentHtml).toContain("Portal");
|
||||
expect(consentHtml).toContain('name="email"');
|
||||
expect(consentHtml).toContain('name="password"');
|
||||
// 缺 PKCE → 400
|
||||
const bad = await app.req(
|
||||
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
|
||||
@@ -281,18 +328,19 @@ describe("oauth flow (整合)", () => {
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET /authorize:MCP_OWNER_SECRET 未設 → 503(不留不安全預設)", async () => {
|
||||
const app = buildApp(baseEnv({ MCP_OWNER_SECRET: undefined }));
|
||||
it("GET /authorize:不需要任何 owner 祕密就看得到同意頁(封測者接自己的 AI 不會死在這頁)", async () => {
|
||||
// 舊行為:未設 MCP_OWNER_SECRET → 503 ⇒ 每個封測者都卡住。現在把關是 Portal 帳密。
|
||||
const app = buildApp(baseEnv());
|
||||
const { challenge } = await pkcePair();
|
||||
const r = await app.req(
|
||||
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
|
||||
"https://claude.ai/cb",
|
||||
)}&code_challenge=${challenge}&code_challenge_method=S256`,
|
||||
);
|
||||
expect(r.status).toBe(503);
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
it("完整 code→token:正確 owner 祕密 + 正確 verifier → access_token", async () => {
|
||||
it("完整 code→token:正確 Portal 帳密 + 正確 verifier → access_token", async () => {
|
||||
const env = baseEnv();
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
@@ -310,7 +358,8 @@ describe("oauth flow (整合)", () => {
|
||||
code_challenge_method: "S256",
|
||||
scope: "mcp",
|
||||
resource: "https://mcp.arcrun.dev/mcp",
|
||||
owner_secret: "s3cr3t-owner",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -344,6 +393,143 @@ describe("oauth flow (整合)", () => {
|
||||
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp");
|
||||
});
|
||||
|
||||
// ── 2026-08-12:身分要接住並攜帶(本次修的病根)─────────────────────────────
|
||||
describe("登入者身分跟著 token 走(leo:掛上 MCP 並輸入帳密=授權,下游不得再問一次)", () => {
|
||||
it("驗完帳密不是只留布林值:token 帶得出 portal session 與該帳號的可用知識庫", async () => {
|
||||
const env = baseEnv({ CYPHER_EXECUTOR: cypherMock({ libraries: ["kb"], displayName: "小明", role: "user" }).fetcher });
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
const redirect = "https://claude.ai/cb";
|
||||
const authRes = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
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,
|
||||
}).toString(),
|
||||
});
|
||||
const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token);
|
||||
expect(at?.portal?.session).toBe("sess-abc");
|
||||
expect(at?.portal?.display_name).toBe("小明");
|
||||
expect(at?.portal?.role).toBe("user");
|
||||
expect(at?.portal?.libraries).toEqual(["kb"]);
|
||||
});
|
||||
|
||||
it("**不同帳號登入 → token 帶的身分跟著換**(不是不管誰登入都同一格)", async () => {
|
||||
// 兩個帳號權限不同:一個全庫、一個只有 kb。token 裡的身分必須各自不同。
|
||||
const envA = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-A", displayName: "Leo", libraries: ["*"] }).fetcher });
|
||||
const envB = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-B", displayName: "小明", libraries: ["kb"] }).fetcher });
|
||||
|
||||
async function tokenFor(env: Env) {
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
const redirect = "https://claude.ai/cb";
|
||||
const a = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
|
||||
const t = 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,
|
||||
}).toString(),
|
||||
});
|
||||
return getAccessToken(env.OAUTH_KV!, (await t.json()).access_token);
|
||||
}
|
||||
|
||||
const a = await tokenFor(envA);
|
||||
const b = await tokenFor(envB);
|
||||
expect(a?.portal?.session).not.toBe(b?.portal?.session);
|
||||
expect(a?.portal?.libraries).toEqual(["*"]);
|
||||
expect(b?.portal?.libraries).toEqual(["kb"]);
|
||||
});
|
||||
|
||||
it("access_token 活不過它底下的 portal session(TTL 取兩者較小)", async () => {
|
||||
const env = baseEnv({
|
||||
MCP_TOKEN_TTL: "2592000", // 30 天
|
||||
CYPHER_EXECUTOR: cypherMock({ sessionExpiresIn: 3600 }).fetcher, // session 只有 1 小時
|
||||
});
|
||||
const app = buildApp(env);
|
||||
const { verifier, challenge } = await pkcePair();
|
||||
const redirect = "https://claude.ai/cb";
|
||||
const a = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
|
||||
const t = 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,
|
||||
}).toString(),
|
||||
});
|
||||
expect((await t.json()).expires_in).toBe(3600);
|
||||
});
|
||||
|
||||
it("cypher 回 200 但沒給 session_token(舊版 cypher)→ 不發碼(不發一張沒有身分的 token)", async () => {
|
||||
const app = buildApp(baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: null }).fetcher }));
|
||||
const { challenge } = await pkcePair();
|
||||
const r = await app.req("/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: "c1",
|
||||
redirect_uri: "https://claude.ai/cb",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(r.status).toBe(401);
|
||||
expect(r.headers.get("location")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("錯誤 owner 祕密 → 401、不發 code", async () => {
|
||||
const app = buildApp(baseEnv());
|
||||
const { challenge } = await pkcePair();
|
||||
@@ -355,7 +541,8 @@ describe("oauth flow (整合)", () => {
|
||||
redirect_uri: "https://claude.ai/cb",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
owner_secret: "WRONG",
|
||||
email: GOOD_EMAIL,
|
||||
password: "WRONG",
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -377,7 +564,8 @@ describe("oauth flow (整合)", () => {
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
owner_secret: "s3cr3t-owner",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -445,7 +633,8 @@ describe("oauth resource(RFC 8707)簽發端把關", () => {
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
resource,
|
||||
owner_secret: "s3cr3t-owner",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
@@ -570,7 +759,8 @@ describe("oauth store drift guard:OAUTH_KV 的 put 一律帶 TTL", () => {
|
||||
redirect_uri: redirect,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
owner_secret: "s3cr3t-owner",
|
||||
email: GOOD_EMAIL,
|
||||
password: GOOD_PASSWORD,
|
||||
}).toString(),
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user