/** * Credential Injector * * 執行順序: * 1. 檢查是否有對應的 auth recipe(auth_recipe:{componentId} in RECIPES KV) * → 有:走 auth recipe 路徑(支援 static_key, service_account) * → 無:走舊有 flat injection 路徑(向後相容) * * Auth Recipe 路徑: * - static_key:展開 inject.header/query/body 的 {{secret.KEY}} 模板 * - service_account:JWT signing → token exchange → 展開 {{runtime.access_token}} * - 注入結果以 _auth_headers / _auth_query / _auth_body 攜帶,不污染業務欄位 * * 舊有路徑(向後相容): * - 從 RECIPES KV 讀取 credentials_required(動態 recipe) * - 或從 BUILTIN_CREDENTIALS_MAP(內建清單) * - 解密後以 inject_as 欄位名稱直接注入 context */ import type { Bindings } from '../types'; import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes'; import type { AuthRecipeDefinition } from '../routes/recipes'; export interface CredentialRequirement { key: string; // CREDENTIALS_KV 的 credential 名稱(如 gmail_token) inject_as: string; // 注入到 input 的欄位名稱(如 access_token) } /** 內建 API recipe 的 credentials_required(對應 component-loader 的 BUILTIN_API_RECIPES)*/ const BUILTIN_CREDENTIALS_MAP: Record = { gmail: [{ key: 'gmail_token', inject_as: 'access_token' }], google_sheets: [{ key: 'google_oauth', inject_as: 'access_token' }], telegram: [{ key: 'telegram_bot_token', inject_as: 'bot_token' }], line_notify: [{ key: 'line_token', inject_as: 'token' }], }; // ── AES-GCM 解密 ────────────────────────────────────────────────────────────── async function decryptCredential(encryptedJson: string, encryptionKey: string): Promise { const { encrypted, iv } = JSON.parse(encryptedJson) as { encrypted: string; iv: string }; const keyBytes = hexToUint8Array(encryptionKey); const cryptoKey = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'], ); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: base64ToUint8Array(iv) }, cryptoKey, base64ToUint8Array(encrypted), ); return new TextDecoder().decode(decrypted); } function hexToUint8Array(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); return bytes; } function base64ToUint8Array(b64: string): Uint8Array { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } // ── 解密所有 required_secrets → { key: decryptedValue } ────────────────────── async function decryptSecrets( recipe: AuthRecipeDefinition, apiKey: string, env: Bindings, ): Promise> { const result: Record = {}; for (const req of recipe.required_secrets) { if (req.optional) continue; const kvKey = `${apiKey}:cred:${req.key}`; const record = await env.CREDENTIALS_KV.get(kvKey); if (!record) { throw new Error( `缺少 credential:${req.key}(${req.label})\n` + `修復步驟:\n` + ` 1. 在 credentials.yaml 加入 ${req.key}: "your-value"\n` + ` 2. 執行:acr creds push`, ); } result[req.key] = await decryptCredential(record, env.ENCRYPTION_KEY); } return result; } // ── Template 展開:{{secret.KEY}} 和 {{runtime.KEY}} ───────────────────────── function interpolateTemplate( template: string, secrets: Record, runtime: Record, ): string { return template.replace(/\{\{(secret|runtime)\.(\w+)\}\}/g, (_, ns, key) => { if (ns === 'secret') return secrets[key] ?? ''; if (ns === 'runtime') return runtime[key] ?? ''; return ''; }); } function interpolateRecord( record: Record, secrets: Record, runtime: Record, ): Record { const result: Record = {}; for (const [k, v] of Object.entries(record)) { result[k] = interpolateTemplate(v, secrets, runtime); } return result; } // ── Auth Recipe 注入(新路徑)──────────────────────────────────────────────── async function injectFromAuthRecipe( recipe: AuthRecipeDefinition, input: Record, env: Bindings, apiKey: string, ): Promise> { // 解密所有 required_secrets const secrets = await decryptSecrets(recipe, apiKey, env); // runtime token:service_account 路徑已改走 auth-dispatcher → auth_service_account WASM; // 這條 TS fallback 只處理 static_key (runtime 為空即可),service_account 永遠不會走到這裡 const runtime: Record = {}; if (recipe.primitive === 'service_account') { throw new Error( `service_account primitive 應由 auth-dispatcher → auth_service_account WASM 處理,` + `不應進到 credential-injector TS fallback (service=${recipe.service})`, ); } // 展開 inject 模板 const authHeaders = recipe.inject.header ? interpolateRecord(recipe.inject.header, secrets, runtime) : {}; const authQuery = recipe.inject.query ? interpolateRecord(recipe.inject.query, secrets, runtime) : {}; const authBody = recipe.inject.body ? interpolateRecord(recipe.inject.body, secrets, runtime) : {}; return { ...input, _auth_headers: authHeaders, _auth_query: authQuery, _auth_body: authBody, }; } // ── 舊有路徑:flat injection(向後相容)────────────────────────────────────── async function loadCredentialsRequired( componentId: string, env: Bindings, ): Promise { const recipe = await resolveRecipe(componentId, env.RECIPES); if (recipe?.credentials_required?.length) { return recipe.credentials_required; } return BUILTIN_CREDENTIALS_MAP[componentId] ?? []; } // ── 主入口 ──────────────────────────────────────────────────────────────────── /** * 執行 credential 注入。 * * @param componentId - 零件 canonical_id 或 hash * @param input - 節點的 merged context * @param env - Cloudflare Worker Bindings * @param apiKey - 用戶的 API Key(ak_前綴),作為 KV namespace */ export async function injectCredentials( componentId: string, input: Record, env: Bindings, apiKey?: string, ): Promise> { // 沒有 api_key → local 模式,略過 if (!apiKey) return input; // ── 新路徑:auth recipe ── const authRecipe = await resolveAuthRecipe(componentId, env.RECIPES); if (authRecipe) { return injectFromAuthRecipe(authRecipe, input, env, apiKey); } // ── 舊路徑:flat injection(向後相容)── const required = await loadCredentialsRequired(componentId, env); if (required.length === 0) return input; const enriched = { ...input }; for (const cred of required) { const kvKey = `${apiKey}:cred:${cred.key}`; const record = await env.CREDENTIALS_KV.get(kvKey); if (!record) { throw new Error( `缺少 credential:${cred.key}\n` + `修復步驟:\n` + ` 1. 在 credentials.yaml 中加入 ${cred.key}: "your-token"\n` + ` 2. 執行:acr creds push`, ); } try { const decrypted = await decryptCredential(record, env.ENCRYPTION_KEY); enriched[cred.inject_as] = decrypted; } catch (e) { throw new Error( `credential "${cred.key}" 解密失敗:${e instanceof Error ? e.message : String(e)}\n` + `修復步驟:重新執行 acr creds push。`, ); } } return enriched; }