Files
Arcrun/mcp/tests/unit/partner-auth.test.ts
T
Claude 92cfb9c59f fix(mcp): address PR #15 review — aud validation, OAUTH_KV auto-inject, TTL/​sunset docs, drift test
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
2026-07-07 04:56:59 +00:00

162 lines
5.8 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.
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 的最小 appprotected 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-AuthenticateRFC 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 URIapp.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);
});
});