feat(auth): auth_service_account WASM primitive + remove TS JWT signer
- registry/components/auth_service_account: TinyGo impl for Google Service Account (JWT-bearer → token exchange) and base structure for AWS SigV4. - .component-builds/auth_service_account: independent Worker at auth-service-account.arcrun.dev, extends wasi-shim with an http_request host function for the token exchange step. - Delete cypher-executor/src/lib/wasm-executor.ts (legacy, replaced by component-loader WASM HTTP runner path). - credential-injector.ts service_account branch now throws — all service_account recipes must route through auth-dispatcher. Per .agents/specs/arcrun/credential-primitives-wasm Phase 2. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,93 +1,53 @@
|
||||
/**
|
||||
* Credential Injector
|
||||
*
|
||||
* 在 WASM 零件執行前,從 CREDENTIALS_KV 讀取加密 credential,
|
||||
* AES-GCM 解密後注入到 input 的對應欄位(inject_as)。
|
||||
* 執行順序:
|
||||
* 1. 檢查是否有對應的 auth recipe(auth_recipe:{componentId} in RECIPES KV)
|
||||
* → 有:走 auth recipe 路徑(支援 static_key, service_account)
|
||||
* → 無:走舊有 flat injection 路徑(向後相容)
|
||||
*
|
||||
* 用戶的 workflow.yaml config 中不需要也不應該包含明文 token。
|
||||
* 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 攜帶,不污染業務欄位
|
||||
*
|
||||
* 設計原則:
|
||||
* - contract.yaml 的 credentials_required 宣告需要哪個 credential
|
||||
* - CREDENTIALS_KV 存放 AES-GCM 加密後的 credential(key = cred:{name})
|
||||
* - 注入發生在 WASM 執行前,不修改 WEBHOOKS KV 中儲存的 workflow 定義
|
||||
* 舊有路徑(向後相容):
|
||||
* - 從 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 的 key(如 gmail_token)
|
||||
type: string; // token 類型(如 google_oauth)
|
||||
description: string; // 說明
|
||||
inject_as: string; // 注入到 input 的欄位名稱(如 access_token)
|
||||
key: string; // CREDENTIALS_KV 的 credential 名稱(如 gmail_token)
|
||||
inject_as: string; // 注入到 input 的欄位名稱(如 access_token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀取並解析零件的 contract.yaml(從 WASM_BUCKET)
|
||||
* 回傳 credentials_required 陣列,若不存在則回傳空陣列
|
||||
*/
|
||||
async function loadCredentialsRequired(
|
||||
componentId: string,
|
||||
wasmBucket: R2Bucket,
|
||||
): Promise<CredentialRequirement[]> {
|
||||
const contractKey = `${componentId}/component.contract.yaml`;
|
||||
const obj = await wasmBucket.get(contractKey);
|
||||
if (!obj) return [];
|
||||
/** 內建 API recipe 的 credentials_required(對應 component-loader 的 BUILTIN_API_RECIPES)*/
|
||||
const BUILTIN_CREDENTIALS_MAP: Record<string, CredentialRequirement[]> = {
|
||||
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' }],
|
||||
};
|
||||
|
||||
const yamlText = await obj.text();
|
||||
return parseCredentialsRequired(yamlText);
|
||||
}
|
||||
// ── AES-GCM 解密 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 從 YAML 文字解析 credentials_required 欄位
|
||||
* 使用簡單的正規表達式解析(避免引入 js-yaml 依賴)
|
||||
*/
|
||||
function parseCredentialsRequired(yaml: string): CredentialRequirement[] {
|
||||
const credsSection = yaml.match(/credentials_required:\s*([\s\S]*?)(?=\n\w|\n#|$)/);
|
||||
if (!credsSection) return [];
|
||||
|
||||
const items: CredentialRequirement[] = [];
|
||||
const blockText = credsSection[1];
|
||||
|
||||
// 解析 " - key: xxx" 開頭的項目
|
||||
const itemMatches = blockText.split(/\n - /).slice(1);
|
||||
for (const item of itemMatches) {
|
||||
const key = item.match(/key:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
||||
const type = item.match(/type:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
||||
const description = item.match(/description:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim() ?? '';
|
||||
const inject_as = item.match(/inject_as:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
||||
|
||||
if (key && type && inject_as) {
|
||||
items.push({ key, type, description, inject_as });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-GCM 解密(與 credentials Worker 的加密邏輯對應)
|
||||
* CREDENTIALS_KV 儲存格式:{ encrypted: base64, iv: base64 }
|
||||
*/
|
||||
async function decryptCredential(encryptedJson: string, encryptionKey: string): Promise<string> {
|
||||
const { encrypted, iv } = JSON.parse(encryptedJson) as { encrypted: string; iv: string };
|
||||
|
||||
// 將 hex-encoded 256-bit key 轉為 CryptoKey
|
||||
const keyBytes = hexToUint8Array(encryptionKey);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt'],
|
||||
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'],
|
||||
);
|
||||
|
||||
const ivBytes = base64ToUint8Array(iv);
|
||||
const cipherBytes = base64ToUint8Array(encrypted);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: ivBytes },
|
||||
{ name: 'AES-GCM', iv: base64ToUint8Array(iv) },
|
||||
cryptoKey,
|
||||
cipherBytes,
|
||||
base64ToUint8Array(encrypted),
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
@@ -95,53 +55,168 @@ async function decryptCredential(encryptedJson: string, encryptionKey: string):
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
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<string, string>,
|
||||
runtime: Record<string, string>,
|
||||
): 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<string, string>,
|
||||
secrets: Record<string, string>,
|
||||
runtime: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
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<string, unknown>,
|
||||
env: Bindings,
|
||||
apiKey: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// 解密所有 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<string, string> = {};
|
||||
|
||||
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<CredentialRequirement[]> {
|
||||
const recipe = await resolveRecipe(componentId, env.RECIPES);
|
||||
if (recipe?.credentials_required?.length) {
|
||||
return recipe.credentials_required;
|
||||
}
|
||||
return BUILTIN_CREDENTIALS_MAP[componentId] ?? [];
|
||||
}
|
||||
|
||||
// ── 主入口 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 執行 credential 注入
|
||||
* 執行 credential 注入。
|
||||
*
|
||||
* @param componentId - 零件 canonical_id
|
||||
* @param input - 節點的原始 input(來自 workflow config)
|
||||
* @param env - Cloudflare Worker Bindings
|
||||
* @returns 注入 credential 後的 input
|
||||
*
|
||||
* @throws 若 credential 不存在,拋出結構化錯誤(含 key 名稱與修復步驟)
|
||||
* @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<string, unknown>,
|
||||
env: Bindings,
|
||||
apiKey?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// 讀取 contract.yaml 中的 credentials_required
|
||||
const required = await loadCredentialsRequired(componentId, env.WASM_BUCKET);
|
||||
// 沒有 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 = `cred:${cred.key}`;
|
||||
const kvKey = `${apiKey}:cred:${cred.key}`;
|
||||
const record = await env.CREDENTIALS_KV.get(kvKey);
|
||||
|
||||
if (!record) {
|
||||
throw new Error(
|
||||
`缺少 credential:${cred.key}(${cred.description})\n` +
|
||||
`缺少 credential:${cred.key}\n` +
|
||||
`修復步驟:\n` +
|
||||
` 1. 在 credentials.yaml 中加入:\n` +
|
||||
` ${cred.key}: "your-${cred.type}-token"\n` +
|
||||
` 2. 執行:acr creds push`
|
||||
` 1. 在 credentials.yaml 中加入 ${cred.key}: "your-token"\n` +
|
||||
` 2. 執行:acr creds push`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,7 +226,7 @@ export async function injectCredentials(
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`credential "${cred.key}" 解密失敗:${e instanceof Error ? e.message : String(e)}\n` +
|
||||
`修復步驟:重新執行 acr creds push 上傳正確的 credential。`
|
||||
`修復步驟:重新執行 acr creds push。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Tier 1 WASM 執行器
|
||||
* 從 R2 載入 .wasm,透過 WASI preview1 shim 執行,stdin/stdout JSON I/O。
|
||||
*
|
||||
* 快取策略:WebAssembly.Module 快取於 Worker 記憶體(跨請求共享),
|
||||
* 避免重複編譯。每次執行只重新 instantiate。
|
||||
*
|
||||
* Requirements: 3.1, 3.3, 6.6
|
||||
*/
|
||||
|
||||
import { createWasiShim, type WasiHostFunctions } from './wasi-shim';
|
||||
|
||||
// Worker 記憶體快取:r2Key → WebAssembly.Module
|
||||
const moduleCache = new Map<string, WebAssembly.Module>();
|
||||
|
||||
export interface WasmExecutorOptions {
|
||||
/** R2 Bucket binding */
|
||||
bucket: R2Bucket;
|
||||
/** R2 物件鍵(例:components/validate_json/v1.wasm) */
|
||||
r2Key: string;
|
||||
/** 逾時上限(ms),對應 contract.constraints.max_cold_start_ms */
|
||||
timeoutMs?: number;
|
||||
/** 可選的 host function 注入(讓 .wasm 呼叫外部服務) */
|
||||
hostFunctions?: WasiHostFunctions;
|
||||
}
|
||||
|
||||
export interface WasmExecuteResult {
|
||||
output: unknown;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行 WASM 零件
|
||||
* @param input - 傳入零件的 JSON 物件(寫入 stdin)
|
||||
* @param options - 執行選項
|
||||
*/
|
||||
export async function executeWasm(
|
||||
input: unknown,
|
||||
options: WasmExecutorOptions,
|
||||
): Promise<WasmExecuteResult> {
|
||||
const { bucket, r2Key, timeoutMs = 50, hostFunctions } = options;
|
||||
|
||||
// ...(其餘不變)
|
||||
const start = Date.now();
|
||||
|
||||
// 1. 取得或編譯 WebAssembly.Module(快取)
|
||||
let wasmModule = moduleCache.get(r2Key);
|
||||
if (!wasmModule) {
|
||||
const obj = await bucket.get(r2Key);
|
||||
if (!obj) throw new Error(`WASM 零件不存在於 R2:${r2Key}`);
|
||||
const arrayBuffer = await obj.arrayBuffer();
|
||||
wasmModule = await WebAssembly.compile(arrayBuffer);
|
||||
moduleCache.set(r2Key, wasmModule);
|
||||
}
|
||||
|
||||
// 2. 建立 WASI shim,注入 stdin 與可選的 host functions
|
||||
const stdinJson = JSON.stringify(input);
|
||||
const shim = createWasiShim(stdinJson, hostFunctions);
|
||||
|
||||
// 3. instantiate(每次執行都重新 instantiate,shim 狀態是獨立的)
|
||||
const instance = await WebAssembly.instantiate(wasmModule, shim.imports);
|
||||
|
||||
// 4. 注入 memory(WASI fd_read/fd_write 需要存取 memory)
|
||||
const memory = instance.exports.memory as WebAssembly.Memory | undefined;
|
||||
if (memory) shim.setMemory(memory);
|
||||
|
||||
// 5. 執行(帶逾時)
|
||||
const exports = instance.exports as Record<string, unknown>;
|
||||
const entryFn = (exports._start ?? exports.main) as (() => void) | undefined;
|
||||
if (typeof entryFn !== 'function') {
|
||||
throw new Error(`WASM 零件缺少 _start 或 main export(r2Key: ${r2Key})`);
|
||||
}
|
||||
|
||||
const runWithTimeout = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`WASM 執行逾時(>${timeoutMs}ms):${r2Key}`));
|
||||
}, timeoutMs);
|
||||
try {
|
||||
entryFn();
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
// proc_exit(0) 拋出 "wasm exit: 0",視為正常結束
|
||||
if (e instanceof Error && e.message === 'wasm exit: 0') {
|
||||
resolve();
|
||||
} else {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await runWithTimeout;
|
||||
|
||||
// 6. 讀取 stdout,JSON.parse
|
||||
const stdout = shim.getStdout().trim();
|
||||
const stderr = shim.getStderr().trim();
|
||||
const duration_ms = Date.now() - start;
|
||||
|
||||
if (!stdout) {
|
||||
throw new Error(`WASM 零件沒有輸出(stdout 為空):${r2Key}`);
|
||||
}
|
||||
|
||||
let output: unknown;
|
||||
try {
|
||||
output = JSON.parse(stdout);
|
||||
} catch {
|
||||
throw new Error(`WASM 零件輸出不是合法 JSON:${stdout.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return { output, stdout, stderr, duration_ms };
|
||||
}
|
||||
|
||||
/** 清除 Module 快取(測試用) */
|
||||
export function clearModuleCache(): void {
|
||||
moduleCache.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user