10d150ac2b
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>
811 lines
32 KiB
TypeScript
811 lines
32 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import { Hono } from "hono";
|
||
import {
|
||
randomToken,
|
||
sha256Base64Url,
|
||
sha256Hex,
|
||
constantTimeEqual,
|
||
verifyPkceS256,
|
||
} from "../../src/oauth/crypto.js";
|
||
import {
|
||
putAuthCode,
|
||
consumeAuthCode,
|
||
putAccessToken,
|
||
getAccessToken,
|
||
} from "../../src/oauth/store.js";
|
||
import {
|
||
originOf,
|
||
resourceUri,
|
||
normalizeResource,
|
||
resourceMatches,
|
||
protectedResourceMetadata,
|
||
authorizationServerMetadata,
|
||
wwwAuthenticateHeader,
|
||
} from "../../src/oauth/metadata.js";
|
||
import { esc, consentPage } from "../../src/oauth/consent.js";
|
||
import { registerOAuthRoutes } from "../../src/oauth/routes.js";
|
||
import type { Env } from "../../src/types.js";
|
||
|
||
// ── 記憶體 KV mock(支援 expirationTtl → 用 exp 過期;delete)─────────────────────
|
||
function makeKV(): KVNamespace {
|
||
const map = new Map<string, { value: string; exp?: number }>();
|
||
const kv = {
|
||
async put(key: string, value: string, opts?: { expirationTtl?: number }) {
|
||
map.set(key, {
|
||
value,
|
||
exp: opts?.expirationTtl ? Date.now() + opts.expirationTtl * 1000 : undefined,
|
||
});
|
||
},
|
||
async get(key: string) {
|
||
const e = map.get(key);
|
||
if (!e) return null;
|
||
if (e.exp && e.exp < Date.now()) {
|
||
map.delete(key);
|
||
return null;
|
||
}
|
||
return e.value;
|
||
},
|
||
async delete(key: string) {
|
||
map.delete(key);
|
||
},
|
||
};
|
||
return kv as unknown as KVNamespace;
|
||
}
|
||
|
||
// PKCE:verifier "1234...43+ chars" → 直接算 challenge。
|
||
async function pkcePair() {
|
||
const verifier = "a".repeat(64);
|
||
const challenge = await sha256Base64Url(verifier);
|
||
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: cypherMock().fetcher,
|
||
KBDB: {} as Fetcher,
|
||
KBDB_INTERNAL_TOKEN: "internal",
|
||
OAUTH_KV: makeKV(),
|
||
MCP_OWNER_NAMESPACE: "leo",
|
||
...over,
|
||
} as Env;
|
||
}
|
||
|
||
// ── crypto ──────────────────────────────────────────────────────────────────────
|
||
describe("oauth/crypto", () => {
|
||
it("randomToken 長度足夠且每次不同", () => {
|
||
const a = randomToken();
|
||
const b = randomToken();
|
||
expect(a).not.toBe(b);
|
||
expect(a.length).toBeGreaterThanOrEqual(40);
|
||
expect(a).toMatch(/^[A-Za-z0-9\-_]+$/); // base64url 無 padding
|
||
});
|
||
|
||
it("sha256Hex/base64url 為已知值", async () => {
|
||
// echo -n "abc" | sha256sum → ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
|
||
expect(await sha256Hex("abc")).toBe(
|
||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||
);
|
||
// RFC 7636 附錄範例:verifier → challenge
|
||
expect(await sha256Base64Url("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")).toBe(
|
||
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||
);
|
||
});
|
||
|
||
it("constantTimeEqual 正確", () => {
|
||
expect(constantTimeEqual("abc", "abc")).toBe(true);
|
||
expect(constantTimeEqual("abc", "abd")).toBe(false);
|
||
expect(constantTimeEqual("abc", "abcd")).toBe(false);
|
||
});
|
||
|
||
it("verifyPkceS256:S256 正確、拒 plain/短 verifier/竄改", async () => {
|
||
const { verifier, challenge } = await pkcePair();
|
||
expect(await verifyPkceS256(verifier, challenge, "S256")).toBe(true);
|
||
expect(await verifyPkceS256(verifier, challenge, "plain")).toBe(false);
|
||
expect(await verifyPkceS256(verifier, challenge, undefined)).toBe(false);
|
||
expect(await verifyPkceS256("short", challenge, "S256")).toBe(false);
|
||
expect(await verifyPkceS256("b".repeat(64), challenge, "S256")).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ── store(短效 KV)────────────────────────────────────────────────────────────
|
||
describe("oauth/store", () => {
|
||
it("authorization code 一次性:consume 後即失效(防重放)", async () => {
|
||
const kv = makeKV();
|
||
await putAuthCode(kv, "code-1", {
|
||
client_id: "c1",
|
||
redirect_uri: "https://claude.ai/cb",
|
||
code_challenge: "cc",
|
||
code_challenge_method: "S256",
|
||
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");
|
||
const second = await consumeAuthCode(kv, "code-1");
|
||
expect(second).toBeNull();
|
||
});
|
||
|
||
it("access token 存取 + exp 過期回 null", async () => {
|
||
const kv = makeKV();
|
||
await putAccessToken(
|
||
kv,
|
||
"tok-1",
|
||
{ namespace: "leo", client_id: "c1", scope: "mcp", aud: "https://mcp/mcp", exp: Math.floor(Date.now() / 1000) + 100 },
|
||
100,
|
||
);
|
||
expect((await getAccessToken(kv, "tok-1"))?.namespace).toBe("leo");
|
||
// 已過期
|
||
await putAccessToken(
|
||
kv,
|
||
"tok-2",
|
||
{ namespace: "leo", client_id: "c1", scope: "mcp", aud: "x", exp: Math.floor(Date.now() / 1000) - 10 },
|
||
100,
|
||
);
|
||
expect(await getAccessToken(kv, "tok-2")).toBeNull();
|
||
});
|
||
|
||
it("KV key 為 hash(不把 raw token 當 key)", async () => {
|
||
// 白盒:store 內部用 sha256Hex,這裡驗 hash 與 raw 不同即可
|
||
expect(await sha256Hex("tok-1")).not.toContain("tok-1");
|
||
});
|
||
});
|
||
|
||
// ── metadata ──────────────────────────────────────────────────────────────────
|
||
describe("oauth/metadata", () => {
|
||
it("originOf / resourceUri", () => {
|
||
expect(originOf("https://Mcp.Arcrun.dev/mcp")).toBe("https://mcp.arcrun.dev");
|
||
expect(resourceUri("https://mcp.arcrun.dev")).toBe("https://mcp.arcrun.dev/mcp");
|
||
});
|
||
it("protectedResourceMetadata 必要欄位", () => {
|
||
const m = protectedResourceMetadata("https://mcp.arcrun.dev");
|
||
expect(m.resource).toBe("https://mcp.arcrun.dev/mcp");
|
||
expect(m.authorization_servers).toEqual(["https://mcp.arcrun.dev"]);
|
||
});
|
||
it("authorizationServerMetadata 必要欄位(S256/code/none)", () => {
|
||
const m = authorizationServerMetadata("https://mcp.arcrun.dev");
|
||
expect(m.authorization_endpoint).toBe("https://mcp.arcrun.dev/authorize");
|
||
expect(m.token_endpoint).toBe("https://mcp.arcrun.dev/token");
|
||
expect(m.registration_endpoint).toBe("https://mcp.arcrun.dev/register");
|
||
expect(m.response_types_supported).toEqual(["code"]);
|
||
expect(m.code_challenge_methods_supported).toEqual(["S256"]);
|
||
expect(m.token_endpoint_auth_methods_supported).toEqual(["none"]);
|
||
});
|
||
it("wwwAuthenticateHeader 指向 protected-resource metadata", () => {
|
||
expect(wwwAuthenticateHeader("https://mcp.arcrun.dev")).toBe(
|
||
'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)────────────────────────────────────────────────────
|
||
describe("oauth/consent", () => {
|
||
it("esc 跳脫 HTML 特殊字元", () => {
|
||
expect(esc(`<script>"&'`)).toBe("<script>"&'");
|
||
});
|
||
it("consentPage 把惡意 state 跳脫掉(不注入)", () => {
|
||
const html = consentPage({
|
||
client_id: "c1",
|
||
redirect_uri: "https://claude.ai/cb",
|
||
state: '"><img src=x onerror=alert(1)>',
|
||
code_challenge: "cc",
|
||
code_challenge_method: "S256",
|
||
scope: "mcp",
|
||
resource: "r",
|
||
});
|
||
expect(html).not.toContain("<img src=x");
|
||
expect(html).toContain("<img src=x");
|
||
});
|
||
});
|
||
|
||
// ── 完整 OAuth 流程(route 整合)──────────────────────────────────────────────
|
||
function buildApp(env: Env) {
|
||
const app = new Hono<{ Bindings: Env }>();
|
||
registerOAuthRoutes(app);
|
||
return {
|
||
req: (path: string, init?: RequestInit) =>
|
||
app.request("https://mcp.arcrun.dev" + path, init, env),
|
||
};
|
||
}
|
||
|
||
describe("oauth flow (整合)", () => {
|
||
it("well-known metadata 端點以 request origin 動態生成", async () => {
|
||
const app = buildApp(baseEnv());
|
||
const r = await app.req("/.well-known/oauth-protected-resource");
|
||
expect(r.status).toBe(200);
|
||
const j = await r.json();
|
||
expect(j.resource).toBe("https://mcp.arcrun.dev/mcp");
|
||
const r2 = await app.req("/.well-known/oauth-authorization-server");
|
||
expect((await r2.json()).issuer).toBe("https://mcp.arcrun.dev");
|
||
});
|
||
|
||
it("/register 回 client_id(public client,無 secret)", async () => {
|
||
const app = buildApp(baseEnv());
|
||
const r = await app.req("/register", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ redirect_uris: ["https://claude.ai/cb"], client_name: "Claude" }),
|
||
});
|
||
expect(r.status).toBe(201);
|
||
const j = await r.json();
|
||
expect(j.client_id).toMatch(/^mcp_/);
|
||
expect(j.token_endpoint_auth_method).toBe("none");
|
||
expect(j).not.toHaveProperty("client_secret");
|
||
});
|
||
|
||
it("/register 擋不允許的 redirect_uri host", async () => {
|
||
const app = buildApp(baseEnv());
|
||
const r = await app.req("/register", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ redirect_uris: ["https://evil.example.com/cb"] }),
|
||
});
|
||
expect(r.status).toBe(400);
|
||
expect((await r.json()).error).toBe("invalid_redirect_uri");
|
||
});
|
||
|
||
it("GET /authorize 要求 PKCE S256 且顯示同意頁", async () => {
|
||
const app = buildApp(baseEnv());
|
||
const { challenge } = await pkcePair();
|
||
const ok = await app.req(
|
||
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
|
||
"https://claude.ai/cb",
|
||
)}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`,
|
||
);
|
||
expect(ok.status).toBe(200);
|
||
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(
|
||
"https://claude.ai/cb",
|
||
)}`,
|
||
);
|
||
expect(bad.status).toBe(400);
|
||
});
|
||
|
||
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(200);
|
||
});
|
||
|
||
it("完整 code→token:正確 Portal 帳密 + 正確 verifier → access_token", async () => {
|
||
const env = baseEnv();
|
||
const app = buildApp(env);
|
||
const { verifier, challenge } = await pkcePair();
|
||
const redirect = "https://claude.ai/cb";
|
||
|
||
// POST /authorize 正確祕密 → 302 帶 code
|
||
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,
|
||
state: "st-1",
|
||
code_challenge: challenge,
|
||
code_challenge_method: "S256",
|
||
scope: "mcp",
|
||
resource: "https://mcp.arcrun.dev/mcp",
|
||
email: GOOD_EMAIL,
|
||
password: GOOD_PASSWORD,
|
||
}).toString(),
|
||
redirect: "manual",
|
||
});
|
||
expect(authRes.status).toBe(302);
|
||
const loc = new URL(authRes.headers.get("location")!);
|
||
expect(loc.searchParams.get("state")).toBe("st-1");
|
||
const code = loc.searchParams.get("code")!;
|
||
expect(code).toBeTruthy();
|
||
|
||
// POST /token 正確 verifier → access_token
|
||
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,
|
||
client_id: "c1",
|
||
}).toString(),
|
||
});
|
||
expect(tokRes.status).toBe(200);
|
||
const tok = await tokRes.json();
|
||
expect(tok.token_type).toBe("Bearer");
|
||
expect(tok.access_token).toBeTruthy();
|
||
expect(tok.expires_in).toBeGreaterThan(0);
|
||
|
||
// 換發的 token 綁定 owner namespace
|
||
const at = await getAccessToken(env.OAUTH_KV!, tok.access_token);
|
||
expect(at?.namespace).toBe("leo");
|
||
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();
|
||
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: "WRONG",
|
||
}).toString(),
|
||
redirect: "manual",
|
||
});
|
||
expect(r.status).toBe(401);
|
||
expect(r.headers.get("location")).toBeNull();
|
||
expect(await r.text()).toContain("不正確");
|
||
});
|
||
|
||
it("/token 錯誤 verifier → invalid_grant;重用 code → invalid_grant", async () => {
|
||
const env = baseEnv();
|
||
const app = buildApp(env);
|
||
const { 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")!;
|
||
|
||
// 錯誤 verifier
|
||
const bad = await app.req("/token", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||
body: new URLSearchParams({
|
||
grant_type: "authorization_code",
|
||
code,
|
||
code_verifier: "z".repeat(64),
|
||
redirect_uri: redirect,
|
||
}).toString(),
|
||
});
|
||
expect(bad.status).toBe(400);
|
||
expect((await bad.json()).error).toBe("invalid_grant");
|
||
|
||
// 該 code 已被 consume(即使失敗也一次性)→ 再用正確 verifier 也 invalid_grant
|
||
const reuse = await app.req("/token", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||
body: new URLSearchParams({
|
||
grant_type: "authorization_code",
|
||
code,
|
||
code_verifier: "a".repeat(64),
|
||
redirect_uri: redirect,
|
||
}).toString(),
|
||
});
|
||
expect((await reuse.json()).error).toBe("invalid_grant");
|
||
});
|
||
|
||
it("未設 OAUTH_KV → /token 回 503(誠實,不假綠)", async () => {
|
||
const app = buildApp(baseEnv({ OAUTH_KV: undefined }));
|
||
const r = await app.req("/token", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||
body: new URLSearchParams({
|
||
grant_type: "authorization_code",
|
||
code: "x",
|
||
code_verifier: "a".repeat(64),
|
||
redirect_uri: "https://claude.ai/cb",
|
||
}).toString(),
|
||
});
|
||
expect(r.status).toBe(503);
|
||
});
|
||
});
|
||
|
||
// ── 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,
|
||
email: GOOD_EMAIL,
|
||
password: GOOD_PASSWORD,
|
||
}).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 以外的東西)。
|
||
describe("oauth store drift guard:OAUTH_KV 的 put 一律帶 TTL", () => {
|
||
function spyKV(): { kv: KVNamespace; puts: Array<{ key: string; opts?: { expirationTtl?: number } }> } {
|
||
const map = new Map<string, string>();
|
||
const puts: Array<{ key: string; opts?: { expirationTtl?: number } }> = [];
|
||
const kv = {
|
||
async put(key: string, value: string, opts?: { expirationTtl?: number }) {
|
||
puts.push({ key, opts });
|
||
map.set(key, value);
|
||
},
|
||
async get(key: string) {
|
||
return map.get(key) ?? null;
|
||
},
|
||
async delete(key: string) {
|
||
map.delete(key);
|
||
},
|
||
} as unknown as KVNamespace;
|
||
return { kv, puts };
|
||
}
|
||
|
||
it("完整 authorize→token 流程中,OAUTH_KV 的每次 put 都有 expirationTtl>0", async () => {
|
||
const { kv, puts } = spyKV();
|
||
const env = baseEnv({ OAUTH_KV: kv });
|
||
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")!;
|
||
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,
|
||
client_id: "c1",
|
||
}).toString(),
|
||
});
|
||
|
||
// 至少發生了 put(code + token 各一),且每一次都帶 TTL。
|
||
expect(puts.length).toBeGreaterThanOrEqual(2);
|
||
for (const p of puts) {
|
||
expect(p.opts?.expirationTtl, `put ${p.key} 缺 expirationTtl`).toBeTypeOf("number");
|
||
expect(p.opts!.expirationTtl!).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
|
||
it("直接呼叫 store 層 putAuthCode / putAccessToken 也一律帶 TTL", async () => {
|
||
const { kv, puts } = spyKV();
|
||
await putAuthCode(kv, "c", {
|
||
client_id: "c1",
|
||
redirect_uri: "https://claude.ai/cb",
|
||
code_challenge: "cc",
|
||
code_challenge_method: "S256",
|
||
scope: "mcp",
|
||
resource: "https://mcp/mcp",
|
||
namespace: "leo",
|
||
});
|
||
await putAccessToken(
|
||
kv,
|
||
"t",
|
||
{ namespace: "leo", client_id: "c1", scope: "mcp", aud: "https://mcp/mcp", exp: 1 },
|
||
100,
|
||
);
|
||
expect(puts).toHaveLength(2);
|
||
for (const p of puts) {
|
||
expect(p.opts?.expirationTtl).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
});
|