// 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"; /** * 登入者的身分(authorize 時用帳密換到,之後跟著 token 走)。 * * leo 2026-08-12:「掛上 MCP 並輸入帳密,那個動作本身就是授權。」 * ⇒ 驗完帳密**不能只留一個布林值**——身分要接住並攜帶,下游才不必再要一次認證。 * * `session` 是 cypher `/portal/login` 發的 portal session token,與人類在 portal 網頁上 * 拿到的完全同一種。它是「取得的暫時性認證」,正合本檔開頭的儲存鐵律(可進 KV、帶 TTL); * access_token 的 TTL 會被夾到不超過它(見 routes.ts),兩者一起到期,不會出現 * 「MCP 還連著、底下 session 早死」的鬼打牆。 * * display_name / role / libraries 只是**給人看的回報值**(arcrun_whoami)。 * 真正的權限判定每次都由 cypher 回讀 user record 現算——這裡的副本不是判準, * 所以管理員改權限或停用帳號會立刻生效,不必等 token 過期。 */ export interface PortalIdentity { session: string; display_name: string; role: string; libraries: string[]; } /** 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; /** 這張 code 是誰換的(帳密驗過的那個人)。 */ portal: PortalIdentity; /** portal session 剩餘秒數(authorize 當下);access_token TTL 不得超過它。 */ portal_session_expires_in: number; } /** access token 綁定的資料。 */ export interface AccessTokenData { namespace: string; client_id: string; scope: string; /** token 的目標受眾(= 本 MCP server 的 canonical URI),驗證時比對。 */ aud: string; /** 過期時間(epoch 秒),與 KV TTL 雙保險。 */ exp: number; /** * 持這張 token 的是誰。**舊 token(本次改版前簽發的)沒有這欄** → undefined, * 知識面工具會誠實要求重新連線,而不是偷偷退回服務金鑰那條老路(fail-closed)。 */ portal?: PortalIdentity; } 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; } }