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:
@@ -0,0 +1,410 @@
|
||||
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,
|
||||
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 };
|
||||
}
|
||||
|
||||
function baseEnv(over: Partial<Env> = {}): Env {
|
||||
return {
|
||||
COMPONENT_REGISTRY: {} as Fetcher,
|
||||
CYPHER_EXECUTOR: {} as Fetcher,
|
||||
KBDB: {} as Fetcher,
|
||||
KBDB_INTERNAL_TOKEN: "internal",
|
||||
OAUTH_KV: makeKV(),
|
||||
MCP_OWNER_SECRET: "s3cr3t-owner",
|
||||
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",
|
||||
});
|
||||
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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
expect(await ok.text()).toContain("Owner 祕密");
|
||||
// 缺 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:MCP_OWNER_SECRET 未設 → 503(不留不安全預設)", async () => {
|
||||
const app = buildApp(baseEnv({ MCP_OWNER_SECRET: undefined }));
|
||||
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(503);
|
||||
});
|
||||
|
||||
it("完整 code→token:正確 owner 祕密 + 正確 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",
|
||||
owner_secret: "s3cr3t-owner",
|
||||
}).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");
|
||||
});
|
||||
|
||||
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",
|
||||
owner_secret: "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",
|
||||
owner_secret: "s3cr3t-owner",
|
||||
}).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);
|
||||
});
|
||||
});
|
||||
@@ -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.5:self-hosted(MULTI_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-hosted:Bearer 明碼即 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 的最小 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("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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user