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, normalizeResource, resourceMatches, 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(); 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 }; } /** * cypher `/portal/login` 的假替身(2026-08-12 起 MCP 的把關就是這支——用使用者自己的 * Portal 帳密,沒有另一把 owner secret)。帳密對 → 回 session_token + 身分欄位;不對 → 401。 */ const GOOD_EMAIL = "leo@example.com"; const GOOD_PASSWORD = "correct horse"; function cypherMock( over: { /** null = 登入成功但**不回** session_token(舊版 cypher);預設回 "sess-abc" */ sessionToken?: string | null; displayName?: string; role?: string; libraries?: string[]; sessionExpiresIn?: number; } = {}, ): { fetcher: Fetcher; calls: Array<{ email: string; password: string }> } { const calls: Array<{ email: string; password: string }> = []; const fetcher = { async fetch(req: Request) { const body = (await req.json()) as { email: string; password: string }; calls.push(body); if (body.email !== GOOD_EMAIL || body.password !== GOOD_PASSWORD) { return new Response(JSON.stringify({ error: "email 或密碼錯誤" }), { status: 401 }); } const sessionToken = over.sessionToken === undefined ? "sess-abc" : over.sessionToken; return new Response( JSON.stringify({ success: true, ...(sessionToken ? { session_token: sessionToken } : {}), display_name: over.displayName ?? "Leo", role: over.role ?? "admin", libraries: over.libraries ?? ["*"], session_expires_in: over.sessionExpiresIn ?? 604800, }), { status: 200, headers: { "content-type": "application/json" } }, ); }, } as unknown as Fetcher; return { fetcher, calls }; } function baseEnv(over: Partial = {}): Env { return { COMPONENT_REGISTRY: {} as Fetcher, CYPHER_EXECUTOR: cypherMock().fetcher, KBDB: {} as Fetcher, KBDB_INTERNAL_TOKEN: "internal", OAUTH_KV: makeKV(), 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", portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] }, portal_session_expires_in: 604800, }); 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"', ); }); it("normalizeResource:尾斜線 / 大小寫 scheme+host / 預設 port 都正規化成 canonical", () => { const canon = "https://mcp.arcrun.dev/mcp"; expect(normalizeResource("https://mcp.arcrun.dev/mcp")).toBe(canon); expect(normalizeResource("https://mcp.arcrun.dev/mcp/")).toBe(canon); // 尾斜線 expect(normalizeResource("HTTPS://Mcp.Arcrun.Dev/mcp")).toBe(canon); // 大小寫 scheme+host expect(normalizeResource("https://mcp.arcrun.dev:443/mcp")).toBe(canon); // 預設 port expect(normalizeResource("not a url")).toBeNull(); }); it("resourceMatches:canonical / 尾斜線變體都 true;別的 host/path false", () => { const origin = "https://mcp.arcrun.dev"; expect(resourceMatches("https://mcp.arcrun.dev/mcp", origin)).toBe(true); expect(resourceMatches("https://mcp.arcrun.dev/mcp/", origin)).toBe(true); expect(resourceMatches("https://other.example.com/mcp", origin)).toBe(false); expect(resourceMatches("https://mcp.arcrun.dev/other", origin)).toBe(false); expect(resourceMatches("garbage", origin)).toBe(false); }); }); // ── consent escaping(XSS)──────────────────────────────────────────────────── describe("oauth/consent", () => { it("esc 跳脫 HTML 特殊字元", () => { expect(esc(`