20c7610371
leo 2026-07-20 明令:「已經改用 cf 自己的 secrets,不要再說它了」 「我希望以後再也看不到這個詞再出現」 背景:credential 早已遷移至 CF Workers per-script Secrets + D1 目錄, 舊的自管金鑰(client 端 AES-GCM + KV 密文 + crypto_decrypt)是遷移期遺留。 本次連根移除,含一併作廢的死 SaaS 碼。 移除: - 舊 KV 密文解密路徑(credential-injector.ts 整檔、dual-read fallback) 前置驗證:leo21c / youlin 兩帳號 CREDENTIALS_KV 實測 *:cred:* 皆 0 筆 - migrate-to-workers-secrets 搬家端點(回填已完成,無可回填) - /register 路由與 generateApiKey(HMAC 產 ak_ key 是 SaaS 遺物; self-hosted 走 namespace 明碼 D21,已無人使用) - platform_crypto component(三帳號實測 404 已退役,無 workflow 引用) 保留(附理由): - crypto_decrypt 保留為永遠回失敗的 stub——現役三個 auth .wasm 仍宣告該 import,缺項會讓 WASM instantiate 直接失敗。待零件重編後可真正刪除。 順帶修復(原不在範圍,但會實際壞事): - /auth/callback 有 `if (!key) redirect(server_error)` 閘,未設該 secret 的 實例會登入直接失敗 → 已移除 - OAuth 兩處把 provider token 寫進舊加密 KV(租戶鍵與實際 api_key 在 rotate 後必然分歧,已失效)→ 改導向 Workers Secrets,包 try/catch 不影響登入 - acr init Standard 模式呼叫已刪除的 /register → 改引導 OAuth 取 key - .claude/rules 與 system-dev/docs 是同一規範的兩份鏡像,先前只改 rules 導致鏡像仍在教舊做法 → 已同步(此類雙檔同步應納入檢查) 新用戶安裝從此零 secret 前置。 測試 187/188(唯一 fail 為 pre-existing,stash 驗證與本次無關); cypher-executor 與 cli typecheck 全綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
/**
|
||
* Auth Dispatcher
|
||
*
|
||
* 對需要認證的零件,在執行前 HTTP POST 到對應的 auth primitive Worker,
|
||
* 取回 auth_headers / auth_query / auth_body 合併進節點 context。
|
||
*
|
||
* 嚴格邊界(rule 02 §2.2):
|
||
* - 本檔**不做**任何 credential 解密 / template 展開 / JWT 簽章
|
||
* - 那些全部在 auth primitive WASM 零件內執行(透過 host function `secret_get` 等)
|
||
* - 本檔只做「查 recipe 決定走哪個 primitive Worker」+「HTTP fetch 取回注入結果」
|
||
*
|
||
* 目前階段接上 `auth_static_key` + `auth_service_account` + `auth_oauth2`,
|
||
* Phase 4 剩 `auth_mtls`(mTLS handshake 在 Worker runtime 層)。
|
||
*
|
||
* 執行時機:graph-executor 在節點 runner 執行前呼叫,取回的 ctx 會:
|
||
* 1. 本 dispatcher 命中 → return enriched ctx
|
||
* 2. 沒命中 → ctx 原樣往下(T10 起舊的 injectCredentials 雙讀 fallback 已移除)
|
||
*/
|
||
|
||
import type { Bindings } from '../types';
|
||
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
|
||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||
import { createArcrunHostFunctions } from '../lib/wasi-shim';
|
||
|
||
// ── credential-store 遷移 T6/T7(方案 A,D19)────────────────────────────────
|
||
//
|
||
// 密文值住 cypher-executor 自己的 per-script secrets(T5 寫入)。解密發生在獨立的
|
||
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
|
||
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref],T4)取明文
|
||
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有
|
||
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
|
||
//
|
||
// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。
|
||
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
|
||
// 內,解密/注入邏輯仍全在 WASM 零件。
|
||
|
||
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
|
||
interface CredentialRefRow {
|
||
name: string;
|
||
secret_ref: string;
|
||
}
|
||
|
||
/**
|
||
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
|
||
*
|
||
* 流程:查 D1 `credentials`(api_key + name)拿 `secret_ref` → 用 `secret_get(ref)`
|
||
* (host function,實作 = env[ref])取值。
|
||
*
|
||
* ⚠️ 只把「D1 有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
|
||
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
|
||
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
|
||
*
|
||
* 取到值的 name 順手更新 D1 `last_used_at`(§2.5 治理面 last_used)。
|
||
*
|
||
* D1 未建表 / migration 未跑 / CREDENTIALS_DB 未綁 → 回空 map(整組走 fallback),
|
||
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
|
||
*/
|
||
export async function resolveSecretsFromNewHome(
|
||
env: Bindings,
|
||
apiKey: string,
|
||
names: string[],
|
||
): Promise<Record<string, string>> {
|
||
const resolved: Record<string, string> = {};
|
||
if (names.length === 0) return resolved;
|
||
|
||
const db = env.CREDENTIALS_DB;
|
||
if (!db) return resolved; // 未綁 D1 → 整組走 fallback
|
||
|
||
// 1. 查 D1 拿每個 name 的 secret_ref
|
||
let rows: CredentialRefRow[];
|
||
try {
|
||
const placeholders = names.map(() => '?').join(', ');
|
||
const result = await db
|
||
.prepare(
|
||
`SELECT name, secret_ref FROM credentials
|
||
WHERE api_key = ? AND name IN (${placeholders})`,
|
||
)
|
||
.bind(apiKey, ...names)
|
||
.all<CredentialRefRow>();
|
||
rows = result.results ?? [];
|
||
} catch {
|
||
// D1 未建表 / query 失敗 → 過渡期整組走 fallback(雙讀),不假綠
|
||
return resolved;
|
||
}
|
||
if (rows.length === 0) return resolved;
|
||
|
||
// 2. 用 secret_ref 從新家取值(host function secret_get = env[ref])
|
||
const secretGet = createArcrunHostFunctions(env, apiKey).secret_get;
|
||
if (!secretGet) return resolved; // host function 未就緒 → 走 fallback
|
||
|
||
const resolvedNames: string[] = [];
|
||
for (const row of rows) {
|
||
const value = await secretGet(row.secret_ref);
|
||
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
|
||
if (value === null) continue;
|
||
resolved[row.name] = value;
|
||
resolvedNames.push(row.name);
|
||
}
|
||
|
||
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name)
|
||
if (resolvedNames.length > 0) {
|
||
try {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const placeholders = resolvedNames.map(() => '?').join(', ');
|
||
await db
|
||
.prepare(
|
||
`UPDATE credentials SET last_used_at = ?
|
||
WHERE api_key = ? AND name IN (${placeholders})`,
|
||
)
|
||
.bind(now, apiKey, ...resolvedNames)
|
||
.run();
|
||
} catch {
|
||
// last_used 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑)
|
||
}
|
||
}
|
||
|
||
return resolved;
|
||
}
|
||
|
||
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
|
||
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']);
|
||
|
||
/** auth primitive 本身的 componentId(避免自引用) */
|
||
const AUTH_PRIMITIVE_IDS = new Set([
|
||
'auth_static_key',
|
||
'auth_service_account',
|
||
'auth_oauth2',
|
||
'auth_mtls',
|
||
]);
|
||
|
||
/**
|
||
* 試著對零件做 auth 注入。
|
||
* - 命中(有對應 auth recipe 且 primitive 已支援)→ 回傳注入後的 ctx
|
||
* - 未命中 → 回傳 null(呼叫端繼續跑舊路徑)
|
||
*/
|
||
export async function tryAuthDispatch(
|
||
componentId: string,
|
||
input: Record<string, unknown>,
|
||
env: Bindings,
|
||
apiKey: string,
|
||
): Promise<Record<string, unknown> | null> {
|
||
if (AUTH_PRIMITIVE_IDS.has(componentId)) {
|
||
// auth primitive 本身不需要再做 auth
|
||
return null;
|
||
}
|
||
|
||
// 決定 auth service name:
|
||
// 1. 若 API recipe 宣告了 auth_service(例 recipe:kbdb_get → "kbdb")→ 用它,
|
||
// 讓多個 recipe 共用同一把 auth_recipe(不必每個 action 複製 auth recipe)。
|
||
// 2. 否則 fallback 到把 componentId 當 service name(向後相容舊行為)。
|
||
let service = componentId;
|
||
const apiRecipe = await resolveRecipe(componentId, env.RECIPES);
|
||
if (apiRecipe?.auth_service) {
|
||
service = apiRecipe.auth_service;
|
||
}
|
||
|
||
const recipe = await resolveAuthRecipe(service, env.RECIPES);
|
||
if (!recipe) return null;
|
||
if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null;
|
||
|
||
// T6:先從新家(cypher per-script secrets)取 recipe 需要的 credential 明文。
|
||
// 拿到的塞進 `resolved_secrets`,WASM 優先用;沒拿到的 name 缺席 → WASM fallback 舊 KV(T7)。
|
||
const secretNames = recipe.required_secrets
|
||
.filter(s => !s.optional)
|
||
.map(s => s.key);
|
||
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, secretNames);
|
||
|
||
// 走新路徑:HTTP POST 到對應 auth primitive Worker
|
||
// 走 workers.dev 避開同 zone 死鎖(P0 #9)
|
||
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN);
|
||
const res = await fetch(primitiveUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
action: 'authenticate',
|
||
api_key: apiKey,
|
||
service,
|
||
// 只在有取到值時帶上(空物件也無妨,WASM 對 nil/空 map 同樣 fallback)
|
||
resolved_secrets: resolvedSecrets,
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '');
|
||
throw new Error(
|
||
`auth primitive "${recipe.primitive}" 回傳 ${res.status}: ${text.slice(0, 200)}`,
|
||
);
|
||
}
|
||
|
||
const result = await res.json().catch(() => null) as {
|
||
success?: boolean;
|
||
error?: string;
|
||
auth_headers?: Record<string, string>;
|
||
auth_query?: Record<string, string>;
|
||
auth_body?: Record<string, string>;
|
||
auth_path?: Record<string, string>;
|
||
} | null;
|
||
|
||
if (!result || result.success === false) {
|
||
throw new Error(
|
||
`auth primitive 失敗: ${result?.error ?? '未知錯誤'}`,
|
||
);
|
||
}
|
||
|
||
return {
|
||
...input,
|
||
_auth_headers: result.auth_headers ?? {},
|
||
_auth_query: result.auth_query ?? {},
|
||
_auth_body: result.auth_body ?? {},
|
||
_auth_path: result.auth_path ?? {},
|
||
};
|
||
}
|
||
|
||
// ── 用戶面 {{credential.NAME}} 注入(design §8)────────────────────────────────
|
||
|
||
/** 匹配 {{credential.NAME}}(NAME 為 word 字元) */
|
||
const CREDENTIAL_REF = /\{\{credential\.(\w+)\}\}/g;
|
||
|
||
/** 遞迴收集任意值(string / 物件 / 陣列)裡所有 {{credential.NAME}} 的 NAME */
|
||
function collectCredentialNames(value: unknown, out: Set<string>): void {
|
||
if (typeof value === 'string') {
|
||
for (const m of value.matchAll(CREDENTIAL_REF)) out.add(m[1]);
|
||
} else if (Array.isArray(value)) {
|
||
for (const v of value) collectCredentialNames(v, out);
|
||
} else if (value && typeof value === 'object') {
|
||
for (const v of Object.values(value as Record<string, unknown>)) collectCredentialNames(v, out);
|
||
}
|
||
}
|
||
|
||
/** 遞迴把 {{credential.NAME}} 替換成 resolved[NAME](未知 name 原樣保留) */
|
||
function replaceCredentialRefs(value: unknown, resolved: Record<string, string>): unknown {
|
||
if (typeof value === 'string') {
|
||
return value.replace(CREDENTIAL_REF, (orig, name: string) =>
|
||
Object.prototype.hasOwnProperty.call(resolved, name) ? resolved[name] : orig,
|
||
);
|
||
}
|
||
if (Array.isArray(value)) return value.map((v) => replaceCredentialRefs(v, resolved));
|
||
if (value && typeof value === 'object') {
|
||
const out: Record<string, unknown> = {};
|
||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||
out[k] = replaceCredentialRefs(v, resolved);
|
||
}
|
||
return out;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
/**
|
||
* 展開節點 data 裡用戶寫的 `{{credential.NAME}}`(design §8)。
|
||
*
|
||
* 嚴格邊界(rule 02 §2.2):本函式**不解密**。偵測到 {{credential.X}} 後,把 names 交給
|
||
* auth_static_key WASM 的 `resolve_credentials` action(WASM 內 kv_get + crypto_decrypt),
|
||
* 拿回明文後只做字串回填。本檔不解密、不持有任何金鑰。
|
||
*
|
||
* - 無 {{credential.}} → 原樣回傳(不打 WASM,零開銷)
|
||
* - 解密失敗 / 缺 credential → throw(誠實報錯,不假綠)
|
||
*/
|
||
export async function resolveCredentialRefs(
|
||
data: Record<string, unknown>,
|
||
env: Bindings,
|
||
apiKey: string,
|
||
): Promise<Record<string, unknown>> {
|
||
const names = new Set<string>();
|
||
collectCredentialNames(data, names);
|
||
if (names.size === 0) return data;
|
||
|
||
const nameList = [...names];
|
||
|
||
// T6:先從新家取這些 credential 的明文,塞 `resolved_secrets`;WASM 優先用它,
|
||
// 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KV(T7)。
|
||
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
|
||
|
||
const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN);
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
action: 'resolve_credentials',
|
||
api_key: apiKey,
|
||
names: nameList,
|
||
resolved_secrets: resolvedSecrets,
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '');
|
||
throw new Error(`credential resolve 回傳 ${res.status}: ${text.slice(0, 200)}`);
|
||
}
|
||
|
||
const result = (await res.json().catch(() => null)) as {
|
||
success?: boolean;
|
||
error?: string;
|
||
credentials?: Record<string, string>;
|
||
} | null;
|
||
|
||
if (!result || result.success === false) {
|
||
throw new Error(`credential resolve 失敗: ${result?.error ?? '未知錯誤'}`);
|
||
}
|
||
|
||
return replaceCredentialRefs(data, result.credentials ?? {}) as Record<string, unknown>;
|
||
}
|