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 }; } function baseEnv(over: Partial = {}): 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"', ); }); 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(`