Files
Arcrun/mcp/tests/unit/partner-auth.test.ts
T
Claude 7d9d478baa feat(mcp): OAuth 2.1 server for claude.ai remote connector; close plaintext-namespace bearer hole
在 arcrun-mcp worker 實作 MCP Authorization 規範(OAuth 2.1 + PKCE S256),
讓 claude.ai 遠端 connector 安全登入;並修掉「Bearer 明碼 namespace 直接放行」漏洞。

安全模型
- /authorize 同意頁以 owner secret(CF Secrets MCP_OWNER_SECRET)把關,只有 owner 知道 →
  只知 URL 的人走不完 OAuth、拿不到 token。
- access_token 是 /mcp 唯一接受的 bearer(預設);明碼 namespace 舊路徑移除(步驟 5 直接 401)。

實作 endpoint(掛 worker 根路徑)
- RFC 9728 /.well-known/oauth-protected-resource(+/mcp 變體)+ 401 帶
  WWW-Authenticate: Bearer resource_metadata=...
- RFC 8414 /.well-known/oauth-authorization-server(response_types=code, S256, none)
- RFC 7591 /register(public client,無 secret,無狀態不落地)
- GET/POST /authorize(PKCE S256 + owner-secret 閘 + redirect_uri 白名單)
- POST /token(authorization_code + PKCE 驗證 → access_token 綁定 owner namespace)

儲存鐵律
- authorization code / access token → 短效 KV OAUTH_KV(key 用 SHA-256 hash、帶 TTL、code 一次性)
- owner secret / static token → CF Secrets(非 KV、非明碼 var)
- DCR client / refresh token → 不落地(無狀態 / 不實作,避免長效機密進 KV)

相容決策
- 本機 CLI/GUI/Claude Code → 用真祕密 MCP_STATIC_TOKEN(CF Secret)取代舊明碼 namespace
- 官方 SaaS partner-key 路徑行為不變
- ALLOW_PLAINTEXT_NAMESPACE 逃生門預設關(僅遷移期)

驗證:tsc exit 0;vitest 42/42(oauth 22 + partner-auth 10 改測真實 middleware + 既有 10);
wrangler deploy --dry-run 打包過、OAUTH_KV binding 正確識別。設計文件 mcp/OAUTH.md。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5jDbuqT5Htwv3Q88XXKk
2026-07-07 03:59:00 +00:00

136 lines
5.0 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();
await putAccessToken(
kv,
"good-token",
{ namespace: "leo", client_id: "c1", scope: "mcp", aud: "x", 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("未知 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);
});
});