// OAuth 短效認證儲存(KV)。儲存鐵律(leo + wiki): // 只有「取得的暫時性認證」可進 KV,且帶 TTL——authorization code、access token 是也。 // 長效機密(owner secret)走 CF Secrets,不進此處。DCR client 不落地(見 OAUTH.md 相容決策)。 // KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。 import { sha256Hex } from "./crypto.js"; /** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */ export interface AuthCodeData { client_id: string; redirect_uri: string; code_challenge: string; code_challenge_method: string; scope: string; /** RFC 8707 resource:綁定 token 的目標 MCP server(audience)。 */ resource: string; /** 換發後 token 綁定的資料分區(owner namespace)。 */ namespace: string; } /** access token 綁定的資料。 */ export interface AccessTokenData { namespace: string; client_id: string; scope: string; /** token 的目標受眾(= 本 MCP server 的 canonical URI),驗證時比對。 */ aud: string; /** 過期時間(epoch 秒),與 KV TTL 雙保險。 */ exp: number; } const CODE_PREFIX = "oauth:code:"; const TOKEN_PREFIX = "oauth:tok:"; /** authorization code 存活秒數(一次性、極短效)。 */ export const AUTH_CODE_TTL_SECONDS = 600; /** 存 authorization code(TTL 極短)。回傳 raw code 給 client。 */ export async function putAuthCode(kv: KVNamespace, code: string, data: AuthCodeData): Promise { const key = CODE_PREFIX + (await sha256Hex(code)); await kv.put(key, JSON.stringify(data), { expirationTtl: AUTH_CODE_TTL_SECONDS }); } /** 取出並「消費」authorization code(一次性:讀到即刪,防重放)。找不到回 null。 */ export async function consumeAuthCode(kv: KVNamespace, code: string): Promise { const key = CODE_PREFIX + (await sha256Hex(code)); const raw = await kv.get(key); if (!raw) return null; await kv.delete(key); // 一次性使用(OAuth 2.1:code 用過必失效) try { return JSON.parse(raw) as AuthCodeData; } catch { return null; } } /** 存 access token(TTL = ttlSeconds)。回傳 raw token 給 client。 */ export async function putAccessToken( kv: KVNamespace, token: string, data: AccessTokenData, ttlSeconds: number, ): Promise { const key = TOKEN_PREFIX + (await sha256Hex(token)); await kv.put(key, JSON.stringify(data), { expirationTtl: ttlSeconds }); } /** 查 access token → 綁定資料。找不到 / 過期回 null(KV TTL 到期會自動消失,另做 exp 雙檢)。 */ export async function getAccessToken(kv: KVNamespace, token: string): Promise { const key = TOKEN_PREFIX + (await sha256Hex(token)); const raw = await kv.get(key); if (!raw) return null; try { const data = JSON.parse(raw) as AccessTokenData; if (typeof data.exp === "number" && data.exp * 1000 < Date.now()) return null; return data; } catch { return null; } }