Files
Arcrun/mcp/tests/unit/oauth.test.ts
T
Claude 5fa3b79a4c fix(mcp): validate/normalize RFC 8707 resource at issuance; store canonical aud
leo review 最後一條:簽發端沒驗證/正規化 resource → 失敗劇本「OAuth 全程成功但每次打 /mcp 401 aud
mismatch」(尾斜線/canonical 變體,錯誤離根因最遠最難 debug)。改在簽發端 fail fast:

- metadata.ts 加 normalizeResource(scheme/host 小寫、去預設 port、path 去尾斜線,與 resourceUri
  canonical 一致)+ resourceMatches。
- /authorize(GET+POST):帶 resource 且正規化後 != canonical → redirect 帶 error=invalid_target
  (redirect_uri 已驗過才 redirect);一律把 canonical resource 存進 code(不存 client 原樣值)。
- /token:帶 resource 且正規化後 != canonical → 400 invalid_target;aud 一律存 canonical
  resourceUri(origin) → 與 partner-auth 嚴格比對 at.aud===resourceUri(origin) 恆一致。

裁決:尾斜線/大小寫等「正規化後等價」的 resource → 接受(存 canonical aud,/mcp 必過),非拒絕——
否則 claude.ai 真送變體會永久授權失敗連不上(把 401 問題換位重現)。只有正規化後真正不同的
resource(別 host/path)才 fail-fast 拒。詳見 OAUTH.md §2。

測試:normalizeResource/resourceMatches 單元 + 尾斜線變體→正常發碼且 aud canonical、別 host→
/authorize redirect invalid_target 不發碼、/token 別 host→400 invalid_target。
mcp vitest 52/52、tsc exit 0。

Refs #15

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

621 lines
24 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 {
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<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;
}
// PKCEverifier "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("verifyPkceS256S256 正確、拒 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("resourceMatchescanonical / 尾斜線變體都 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(`<script>"&'`)).toBe("&lt;script&gt;&quot;&amp;&#39;");
});
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("&lt;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_idpublic 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 /authorizeMCP_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);
});
});
// ── RFC 8707 resource 簽發端驗證/正規化 ─────────────────────────────────────────
// 決策:resource 正規化後 == canonical → 接受(尾斜線/大小寫/預設 port 皆等價,aud 一律存 canonical
// /mcp 嚴格比對必過);正規化後 != canonical(別的 host/path)→ 簽發端 fail-fast 拒 invalid_target。
// 若把尾斜線也拒,claude.ai 真送尾斜線變體會永久連不上(把 401 問題換位重現),故等價形接受才對。
describe("oauth resourceRFC 8707)簽發端把關", () => {
const redirect = "https://claude.ai/cb";
async function postAuthorize(app: ReturnType<typeof buildApp>, resource: string, challenge: string) {
return app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
state: "st",
code_challenge: challenge,
code_challenge_method: "S256",
resource,
owner_secret: "s3cr3t-owner",
}).toString(),
redirect: "manual",
});
}
it("尾斜線變體(等價 canonical)→ 正常發碼,且換出 token 的 aud 為 canonical", async () => {
const env = baseEnv();
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const r = await postAuthorize(app, "https://mcp.arcrun.dev/mcp/", challenge);
expect(r.status).toBe(302);
const loc = new URL(r.headers.get("location")!);
expect(loc.searchParams.get("error")).toBeNull(); // 未被拒
const code = loc.searchParams.get("code")!;
expect(code).toBeTruthy();
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,
resource: "https://mcp.arcrun.dev/mcp/", // token 端也帶尾斜線 → 正規化後仍通過
}).toString(),
});
expect(tokRes.status).toBe(200);
const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token);
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp"); // 存的是 canonical,與 partner-auth 嚴格比對一致
});
it("正確 canonical resource → 正常發碼", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await postAuthorize(app, "https://mcp.arcrun.dev/mcp", challenge);
expect(r.status).toBe(302);
expect(new URL(r.headers.get("location")!).searchParams.get("code")).toBeTruthy();
});
it("GET /authorize:別的 host 的 resource → redirect 帶 error=invalid_target(不顯示同意頁)", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(redirect)}` +
`&code_challenge=${challenge}&code_challenge_method=S256&state=st` +
`&resource=${encodeURIComponent("https://evil.example.com/mcp")}`,
);
expect(r.status).toBe(302);
const loc = new URL(r.headers.get("location")!);
expect(loc.searchParams.get("error")).toBe("invalid_target");
expect(loc.searchParams.get("state")).toBe("st");
expect(loc.searchParams.get("code")).toBeNull();
});
it("POST /authorize:別的 host 的 resource → redirect invalid_target,不發碼", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await postAuthorize(app, "https://evil.example.com/mcp", challenge);
expect(r.status).toBe(302);
const loc = new URL(r.headers.get("location")!);
expect(loc.searchParams.get("error")).toBe("invalid_target");
expect(loc.searchParams.get("code")).toBeNull();
});
it("POST /token:別的 host 的 resource → 400 invalid_target", async () => {
const env = baseEnv();
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
// 先正常拿一個 codeauthorize 不帶 resource → 存 canonical
const authRes = await postAuthorize(app, "https://mcp.arcrun.dev/mcp", challenge);
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
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,
resource: "https://evil.example.com/mcp", // token 端 resource 不符 → 拒
}).toString(),
});
expect(tokRes.status).toBe(400);
expect((await tokRes.json()).error).toBe("invalid_target");
});
});
// ── 防 drift:對 OAUTH_KV 的每一次 put 都必須帶 expirationTtl(守儲存鐵律)──────────
// spy KV 記錄所有 put(key,value,opts);跑完整流程後斷言沒有任何一次「無 TTL」的 put,
// 防未來有人往這顆短效 KV 塞長效資料(access_token / code 以外的東西)。
describe("oauth store drift guardOAUTH_KV 的 put 一律帶 TTL", () => {
function spyKV(): { kv: KVNamespace; puts: Array<{ key: string; opts?: { expirationTtl?: number } }> } {
const map = new Map<string, string>();
const puts: Array<{ key: string; opts?: { expirationTtl?: number } }> = [];
const kv = {
async put(key: string, value: string, opts?: { expirationTtl?: number }) {
puts.push({ key, opts });
map.set(key, value);
},
async get(key: string) {
return map.get(key) ?? null;
},
async delete(key: string) {
map.delete(key);
},
} as unknown as KVNamespace;
return { kv, puts };
}
it("完整 authorize→token 流程中,OAUTH_KV 的每次 put 都有 expirationTtl>0", async () => {
const { kv, puts } = spyKV();
const env = baseEnv({ OAUTH_KV: kv });
const app = buildApp(env);
const { verifier, 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")!;
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(),
});
// 至少發生了 putcode + token 各一),且每一次都帶 TTL。
expect(puts.length).toBeGreaterThanOrEqual(2);
for (const p of puts) {
expect(p.opts?.expirationTtl, `put ${p.key} 缺 expirationTtl`).toBeTypeOf("number");
expect(p.opts!.expirationTtl!).toBeGreaterThan(0);
}
});
it("直接呼叫 store 層 putAuthCode / putAccessToken 也一律帶 TTL", async () => {
const { kv, puts } = spyKV();
await putAuthCode(kv, "c", {
client_id: "c1",
redirect_uri: "https://claude.ai/cb",
code_challenge: "cc",
code_challenge_method: "S256",
scope: "mcp",
resource: "https://mcp/mcp",
namespace: "leo",
});
await putAccessToken(
kv,
"t",
{ namespace: "leo", client_id: "c1", scope: "mcp", aud: "https://mcp/mcp", exp: 1 },
100,
);
expect(puts).toHaveLength(2);
for (const p of puts) {
expect(p.opts?.expirationTtl).toBeGreaterThan(0);
}
});
});