92cfb9c59f
leo review 5 條逐條處理: 1. RFC 8707 aud 驗證(真缺口):partner-auth OAuth 路徑補「at.aud === resourceUri(originOf(url))」, 不符回 401 invalid_token(防別的 arcrun-mcp 部署簽的 token passthrough)。加 aud 不符→401 測試。 2. deploy.ts injectWranglerConfig 涵蓋 OAUTH_KV(在 PR 內補):OAUTH_KV 納入 REQUIRED_KV_NAMESPACES → acr init/update 自動建 namespace + 注入用戶帳號真 id(比照 SUBMISSIONS_KV 家族)。wrangler.toml 註解 更新(CLI 路徑自動、手動直推才需手建)。注入 regex 已驗證命中。 3. MCP_TOKEN_TTL 預設維持 30 天(leo 拍板不改):OAUTH.md 明寫為有意取捨(無 refresh token → 到期重走 OAuth=再輸 owner secret),MCP_TOKEN_TTL 可調、7 天為更保守選項。per-owner 可調另開 issue #19(非阻塞)。 4. ALLOW_PLAINTEXT_NAMESPACE 逃生門標 SUNSET(code + wrangler.toml + OAUTH.md),開 issue #18 追蹤 「遷移完成後移除整段 code path + Env 欄位」。 5. 防 drift 測試:spy KV 攔所有 put,斷言對 OAUTH_KV 的每一次 put 都帶 expirationTtl>0(完整流程 + store 層兩道),防未來往這顆短效 KV 塞長效資料。 驗證:mcp tsc exit 0、vitest 45/45(+aud +drift×2);cli tsc exit 0。 Refs #15 #18 #19 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
162 lines
5.8 KiB
TypeScript
162 lines
5.8 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import { Hono } from "hono";
|
||
import { partnerAuthMiddleware } from "../../src/middleware/partner-auth.js";
|
||
import { putAccessToken } from "../../src/oauth/store.js";
|
||
import type { Env } from "../../src/types.js";
|
||
|
||
// ── 記憶體 KV mock ────────────────────────────────────────────────────────────
|
||
function makeKV(): KVNamespace {
|
||
const map = new Map<string, string>();
|
||
return {
|
||
async put(k: string, v: string) {
|
||
map.set(k, v);
|
||
},
|
||
async get(k: string) {
|
||
return map.get(k) ?? null;
|
||
},
|
||
async delete(k: string) {
|
||
map.delete(k);
|
||
},
|
||
} as unknown as KVNamespace;
|
||
}
|
||
|
||
// 假 KBDB Fetcher:依 URL 回傳 partner 驗證結果。
|
||
function makeKBDB(valid: boolean, ns = "org-a"): Fetcher {
|
||
return {
|
||
async fetch() {
|
||
return new Response(JSON.stringify({ valid, org_namespace: ns }), {
|
||
status: valid ? 200 : 401,
|
||
});
|
||
},
|
||
} as unknown as Fetcher;
|
||
}
|
||
|
||
function baseEnv(over: Partial<Env> = {}): Env {
|
||
return {
|
||
COMPONENT_REGISTRY: {} as Fetcher,
|
||
CYPHER_EXECUTOR: {} as Fetcher,
|
||
KBDB: makeKBDB(true),
|
||
KBDB_INTERNAL_TOKEN: "internal",
|
||
...over,
|
||
} as Env;
|
||
}
|
||
|
||
// 建一個掛了 partnerAuthMiddleware 的最小 app,protected handler 回傳解出的 namespace。
|
||
function buildApp(env: Env) {
|
||
const app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>();
|
||
app.get("/mcp", partnerAuthMiddleware, (c) =>
|
||
c.json({ org_namespace: c.get("org_namespace"), partner_token: c.get("partner_token") }),
|
||
);
|
||
return (auth?: string) =>
|
||
app.request("https://mcp.arcrun.dev/mcp", auth ? { headers: { Authorization: auth } } : {}, env);
|
||
}
|
||
|
||
describe("partner-auth: 無 / 壞 Authorization → 401 + WWW-Authenticate(RFC 9728)", () => {
|
||
it("缺 header → 401 帶 WWW-Authenticate resource_metadata", async () => {
|
||
const r = await buildApp(baseEnv({ MULTI_TENANT: "false" }))();
|
||
expect(r.status).toBe(401);
|
||
expect(r.headers.get("WWW-Authenticate")).toBe(
|
||
'Bearer resource_metadata="https://mcp.arcrun.dev/.well-known/oauth-protected-resource"',
|
||
);
|
||
});
|
||
|
||
it("非 Bearer → 401", async () => {
|
||
const r = await buildApp(baseEnv())("Basic abc");
|
||
expect(r.status).toBe(401);
|
||
expect(r.headers.get("WWW-Authenticate")).toContain("resource_metadata=");
|
||
});
|
||
});
|
||
|
||
describe("partner-auth: OAuth access token 路徑(遠端 claude.ai)", () => {
|
||
it("有效 access_token → 解出綁定 namespace", async () => {
|
||
const kv = makeKV();
|
||
// aud 必須 == 本次請求 origin 的 canonical resource URI(app.request 打 https://mcp.arcrun.dev/mcp)
|
||
await putAccessToken(
|
||
kv,
|
||
"good-token",
|
||
{
|
||
namespace: "leo",
|
||
client_id: "c1",
|
||
scope: "mcp",
|
||
aud: "https://mcp.arcrun.dev/mcp",
|
||
exp: Math.floor(Date.now() / 1000) + 100,
|
||
},
|
||
100,
|
||
);
|
||
const r = await buildApp(baseEnv({ MULTI_TENANT: "false", OAUTH_KV: kv }))("Bearer good-token");
|
||
expect(r.status).toBe(200);
|
||
expect((await r.json()).org_namespace).toBe("leo");
|
||
});
|
||
|
||
it("RFC 8707 aud 不符 → 401 invalid_token(防 token passthrough)", async () => {
|
||
const kv = makeKV();
|
||
await putAccessToken(
|
||
kv,
|
||
"wrong-aud-token",
|
||
{
|
||
namespace: "leo",
|
||
client_id: "c1",
|
||
scope: "mcp",
|
||
aud: "https://other-mcp.example.com/mcp", // 別的部署簽的 token
|
||
exp: Math.floor(Date.now() / 1000) + 100,
|
||
},
|
||
100,
|
||
);
|
||
const r = await buildApp(baseEnv({ MULTI_TENANT: "false", OAUTH_KV: kv }))("Bearer wrong-aud-token");
|
||
expect(r.status).toBe(401);
|
||
expect(r.headers.get("WWW-Authenticate")).toContain("resource_metadata=");
|
||
});
|
||
|
||
it("未知 token(非 OAuth、非 static)在 self-hosted → 401(明碼 namespace 不再放行)", async () => {
|
||
const kv = makeKV();
|
||
const r = await buildApp(baseEnv({ MULTI_TENANT: "false", OAUTH_KV: kv }))("Bearer leo");
|
||
expect(r.status).toBe(401);
|
||
expect(r.headers.get("WWW-Authenticate")).toContain("resource_metadata=");
|
||
});
|
||
});
|
||
|
||
describe("partner-auth: MCP_STATIC_TOKEN 相容路徑(本機 CLI/GUI 真祕密)", () => {
|
||
it("Bearer == static token → owner namespace", async () => {
|
||
const r = await buildApp(
|
||
baseEnv({ MULTI_TENANT: "false", MCP_STATIC_TOKEN: "real-secret-xyz", MCP_OWNER_NAMESPACE: "leo" }),
|
||
)("Bearer real-secret-xyz");
|
||
expect(r.status).toBe(200);
|
||
expect((await r.json()).org_namespace).toBe("leo");
|
||
});
|
||
|
||
it("Bearer != static token(明碼 namespace)→ 401", async () => {
|
||
const r = await buildApp(
|
||
baseEnv({ MULTI_TENANT: "false", MCP_STATIC_TOKEN: "real-secret-xyz" }),
|
||
)("Bearer leo");
|
||
expect(r.status).toBe(401);
|
||
});
|
||
});
|
||
|
||
describe("partner-auth: 官方 SaaS partner-key(行為不變)", () => {
|
||
it("合法 partner key → org_namespace", async () => {
|
||
const r = await buildApp(baseEnv({ KBDB: makeKBDB(true, "org-a") }))("Bearer pk_live_x");
|
||
expect(r.status).toBe(200);
|
||
expect((await r.json()).org_namespace).toBe("org-a");
|
||
});
|
||
|
||
it("非法 partner key → 401", async () => {
|
||
const r = await buildApp(baseEnv({ KBDB: makeKBDB(false) }))("Bearer pk_bad");
|
||
expect(r.status).toBe(401);
|
||
});
|
||
});
|
||
|
||
describe("partner-auth: 明碼逃生門(預設關)", () => {
|
||
it("ALLOW_PLAINTEXT_NAMESPACE=true → 恢復舊明碼行為(遷移期)", async () => {
|
||
const r = await buildApp(
|
||
baseEnv({ MULTI_TENANT: "false", ALLOW_PLAINTEXT_NAMESPACE: "true" }),
|
||
)("Bearer leo");
|
||
expect(r.status).toBe(200);
|
||
expect((await r.json()).org_namespace).toBe("leo");
|
||
});
|
||
|
||
it("預設(未設逃生門)明碼 namespace 被擋", async () => {
|
||
const r = await buildApp(baseEnv({ MULTI_TENANT: "false" }))("Bearer leo");
|
||
expect(r.status).toBe(401);
|
||
});
|
||
});
|