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
This commit is contained in:
Claude
2026-07-07 03:59:00 +00:00
parent befc63cfe0
commit 7d9d478baa
12 changed files with 1352 additions and 99 deletions
+125 -70
View File
@@ -1,80 +1,135 @@
import { describe, it, expect, vi } from "vitest";
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";
// Unit tests for partner-auth middleware logic
// Tests the auth extraction and validation behaviour without a live KBDB
function extractBearerToken(authHeader: string | undefined): string | null {
if (!authHeader?.startsWith("Bearer ")) return null;
return authHeader.slice(7);
// ── 記憶體 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;
}
describe("partner-auth: token extraction", () => {
it("returns null when Authorization header is missing", () => {
expect(extractBearerToken(undefined)).toBeNull();
});
it("returns null when header does not start with 'Bearer '", () => {
expect(extractBearerToken("Basic abc123")).toBeNull();
expect(extractBearerToken("bearer abc123")).toBeNull();
expect(extractBearerToken("Token abc123")).toBeNull();
});
it("extracts token from valid Bearer header", () => {
expect(extractBearerToken("Bearer my-secret-key")).toBe("my-secret-key");
});
it("handles token with special characters", () => {
expect(extractBearerToken("Bearer abc.def_ghi-123")).toBe("abc.def_ghi-123");
});
});
describe("partner-auth: KBDB response validation", () => {
it("rejects when valid is false", () => {
const info = { valid: false, org_namespace: "org-a" };
expect(info.valid).toBe(false);
});
it("accepts when valid is true and extracts org_namespace", () => {
const info = { valid: true, org_namespace: "org-a" };
expect(info.valid).toBe(true);
expect(info.org_namespace).toBe("org-a");
});
});
// HANDOFF §3b / mcp-account-source.md §5.5self-hostedMULTI_TENANT=false)下
// Bearer 帶的是 namespace 明碼,不打 KBDB partner 驗證,直接當 org_namespace。
// 與 cypher-executor 的 opaque-key 模型對齊(X-Arcrun-API-Key 不驗證直接當分區 key)。
function resolveNamespace(
multiTenant: string | undefined,
token: string,
validatePartner: (t: string) => { valid: boolean; org_namespace: string },
): { ok: boolean; org_namespace?: string } {
if (multiTenant === "false") {
// self-hostedBearer 明碼即 namespace,繞 partner 驗證
return { ok: true, org_namespace: token };
}
// SaaS:維持 partner-key 驗證(行為不變)
const info = validatePartner(token);
return info.valid ? { ok: true, org_namespace: info.org_namespace } : { ok: false };
// 假 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;
}
describe("partner-auth: self-hosted (MULTI_TENANT=false) bypasses partner validation", () => {
const partnerValidatorThatAlwaysRejects = () => ({ valid: false, org_namespace: "" });
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;
}
it("self-hosted: namespace 明碼直接當 org_namespace,不打 partner 驗證", () => {
const r = resolveNamespace("false", "leo", partnerValidatorThatAlwaysRejects);
expect(r.ok).toBe(true);
expect(r.org_namespace).toBe("leo");
// 建一個掛了 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("SaaS (未設 MULTI_TENANT):仍走 partner 驗證,明碼被擋", () => {
const r = resolveNamespace(undefined, "leo", partnerValidatorThatAlwaysRejects);
expect(r.ok).toBe(false);
});
it("SaaS:合法 partner key 通過並取 org_namespace", () => {
const r = resolveNamespace("true", "pk_live_x", () => ({ valid: true, org_namespace: "org-a" }));
expect(r.ok).toBe(true);
expect(r.org_namespace).toBe("org-a");
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);
});
});