Files
Arcrun/cypher-executor/src/routes/auth.ts
T
Leo 20c7610371 refactor: 移除已廢棄的自管加密金鑰機制(credential 全面託管 CF Workers Secrets)
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>
2026-07-21 01:32:48 +08:00

468 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// arcrun OAuth 登入路由
// GET /auth/google/start → redirect to Google OAuth
// GET /auth/github/start → redirect to GitHub OAuth
// GET /auth/callback → exchange code, create session
// POST /auth/logout → clear session cookie
// GET /me → current user info
// PUT /me/api-key/rotate → generate new api key
// DELETE /me/api-key → revoke api key
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { storeCredential } from './credentials';
export const authRouter = new Hono<{ Bindings: Bindings }>();
// ─── Types ────────────────────────────────────────────────────────────────────
type UserRecord = {
email: string;
display_name: string;
avatar_url?: string;
api_key: string;
provider: 'google' | 'github';
provider_id: string;
created_at: string;
revoked?: boolean;
};
type SessionRecord = {
user_key: string; // "user:{provider}:{provider_id}"
api_key: string;
email: string;
expires_at: number; // unix timestamp ms
};
type OAuthStateRecord = {
provider: 'google' | 'github';
redirect_back: string;
created_at: number;
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getLandingOrigin(c: { req: { raw: Request } }): string {
const origin = c.req.raw.headers.get('origin');
// 允許的 landing origins
const allowed = ['https://arcrun.dev', 'https://www.arcrun.dev'];
if (origin && allowed.includes(origin)) return origin;
return 'https://arcrun.dev';
}
/**
* 產生 API Key(隨機,與 /me/api-key/rotate 同一套)。
*
* key 與 email 無關、不可預測;登入走 USERS_KV 讀出的 api_key,不重算。
*/
function generateApiKey(): string {
return 'ak_' + randomToken(24);
}
/** 幂等寫入 auth_recipe 到 RECIPES KV(若已存在相同版本則跳過) */
async function upsertAuthRecipe(recipes: KVNamespace, recipe: Record<string, unknown>): Promise<void> {
const key = `auth_recipe:${recipe.service}`;
const existing = await recipes.get(key);
if (existing) return; // 已存在,不覆蓋(用戶可能已自訂)
await recipes.put(key, JSON.stringify({ ...recipe, created_at: Date.now(), updated_at: Date.now() }));
}
/** 產生隨機 token(用於 session ID 和 state */
function randomToken(bytes = 32): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
/** 從 Cookie header 取 session ID */
function getSessionId(req: Request): string | null {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/arcrun_session=([a-f0-9]+)/);
return match ? match[1] : null;
}
/** 從 Request 取 API KeyX-Arcrun-API-Key header 或 Authorization: Bearer */
function getApiKeyFromRequest(req: Request): string | null {
const direct = req.headers.get('x-arcrun-api-key');
if (direct) return direct;
const auth = req.headers.get('authorization') ?? '';
const match = auth.match(/^Bearer\s+(ak_\S+)/i);
return match ? match[1] : null;
}
/** 驗證 session → 回傳 user record,或 null */
async function resolveSession(c: { req: { raw: Request }; env: Bindings }): Promise<UserRecord | null> {
const sessId = getSessionId(c.req.raw);
if (sessId) {
const sess = await c.env.SESSIONS_KV.get<SessionRecord>(`sess:${sessId}`, 'json');
if (sess && sess.expires_at > Date.now()) {
const user = await c.env.USERS_KV.get<UserRecord>(sess.user_key, 'json');
if (user && !user.revoked) return user;
}
}
// Fallback: API Key header
const apiKey = getApiKeyFromRequest(c.req.raw);
if (apiKey) {
// 掃描 USERS_KV by api_key 太慢;改用 reverse index: apikey:{ak_...} → user_key
const userKey = await c.env.USERS_KV.get(`apikey:${apiKey}`);
if (userKey) {
const user = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
if (user && !user.revoked && user.api_key === apiKey) return user;
}
}
return null;
}
// ─── Google OAuth ─────────────────────────────────────────────────────────────
authRouter.get('/auth/google/start', async (c) => {
const clientId = c.env.GOOGLE_CLIENT_ID;
if (!clientId) return c.json({ error: 'Google OAuth not configured' }, 503);
const state = randomToken(16);
const stateRecord: OAuthStateRecord = {
provider: 'google',
redirect_back: c.req.query('redirect') ?? '/dashboard',
created_at: Date.now(),
};
// state TTL = 10 minutes
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid profile email',
state,
access_type: 'offline',
prompt: 'consent',
});
return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
});
// ─── GitHub OAuth ─────────────────────────────────────────────────────────────
authRouter.get('/auth/github/start', async (c) => {
const clientId = c.env.GITHUB_CLIENT_ID;
if (!clientId) return c.json({ error: 'GitHub OAuth not configured' }, 503);
const state = randomToken(16);
const stateRecord: OAuthStateRecord = {
provider: 'github',
redirect_back: c.req.query('redirect') ?? '/dashboard',
created_at: Date.now(),
};
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read:user user:email',
state,
});
return Response.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
});
// ─── OAuth Callback ───────────────────────────────────────────────────────────
authRouter.get('/auth/callback', async (c) => {
const code = c.req.query('code');
const state = c.req.query('state');
const error = c.req.query('error');
const landingOrigin = getLandingOrigin(c);
if (error || !code || !state) {
return Response.redirect(`${landingOrigin}/login?error=${encodeURIComponent(error ?? 'cancelled')}`, 302);
}
// Validate state
const stateRecord = await c.env.SESSIONS_KV.get<OAuthStateRecord>(`state:${state}`, 'json');
if (!stateRecord) {
return Response.redirect(`${landingOrigin}/login?error=invalid_state`, 302);
}
await c.env.SESSIONS_KV.delete(`state:${state}`);
try {
let email: string;
let displayName: string;
let avatarUrl: string | undefined;
let providerId: string;
// provider token 要存進 credential 新家,但必須用最終的 api_key 當租戶鍵,
// 而 api_key 要等下方 USERS_KV upsert 才決定 → 先暫存,稍後再寫。
let pendingCredential: { name: string; value: string; service: string } | null = null;
const provider = stateRecord.provider;
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
if (provider === 'google') {
// Exchange code for token
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
if (!tokenRes.ok) throw new Error('google token exchange failed');
const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string };
// Get user info
const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
if (!userRes.ok) throw new Error('google userinfo failed');
const userInfo = await userRes.json() as {
sub: string; email: string; name: string; picture?: string;
};
email = userInfo.email.toLowerCase();
displayName = userInfo.name;
avatarUrl = userInfo.picture;
providerId = userInfo.sub;
// 存 Google refresh_token 供 auth_oauth2 零件使用(實際寫入在 apiKey 決定後,見下方
// pendingCredential)。Google 只在首次授權時回傳 refresh_token,後續登入為 undefined
if (tokenData.refresh_token) {
pendingCredential = { name: 'google_refresh_token', value: tokenData.refresh_token, service: 'google_user' };
// 種 auth_recipe:google_user(用戶自己的 Google OAuth2
void upsertAuthRecipe(c.env.RECIPES, {
kind: 'auth_recipe',
service: 'google_user',
version: 1,
primitive: 'oauth2',
base_url: 'https://www.googleapis.com',
display_name: 'Google(用戶帳號)',
oauth2: {
token_endpoint: 'https://oauth2.googleapis.com/token',
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
scopes: ['https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/spreadsheets'],
},
required_secrets: [{ key: 'google_refresh_token', label: 'Google Refresh Token' }],
inject: { header: { Authorization: 'Bearer {{runtime.access_token}}' } },
});
}
} else {
// GitHub: exchange code for token
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: new URLSearchParams({
code,
client_id: c.env.GITHUB_CLIENT_ID ?? '',
client_secret: c.env.GITHUB_CLIENT_SECRET ?? '',
redirect_uri: redirectUri,
}),
});
if (!tokenRes.ok) throw new Error('github token exchange failed');
const tokenData = await tokenRes.json() as { access_token: string; token_type?: string };
// Get user info
const userRes = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'User-Agent': 'arcrun',
'Accept': 'application/vnd.github+json',
},
});
if (!userRes.ok) throw new Error('github user fetch failed');
const userInfo = await userRes.json() as {
id: number; login: string; name?: string; avatar_url?: string; email?: string;
};
// GitHub email might be null if private; fetch emails list
let ghEmail = userInfo.email ?? '';
if (!ghEmail) {
const emailsRes = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'User-Agent': 'arcrun',
'Accept': 'application/vnd.github+json',
},
});
if (emailsRes.ok) {
const emails = await emailsRes.json() as { email: string; primary: boolean; verified: boolean }[];
const primary = emails.find(e => e.primary && e.verified);
ghEmail = primary?.email ?? emails[0]?.email ?? '';
}
}
if (!ghEmail) throw new Error('github email not available');
email = ghEmail.toLowerCase();
displayName = userInfo.name ?? userInfo.login;
avatarUrl = userInfo.avatar_url;
providerId = String(userInfo.id);
// 存 GitHub access_token 供 auth_static_key 零件使用(實際寫入在 apiKey 決定後,見
// 下方 pendingCredential)。GitHub 沒有 refresh_tokenaccess_token 長效(直到 revoke
if (tokenData.access_token) {
pendingCredential = { name: 'github_access_token', value: tokenData.access_token, service: 'github_user' };
// GitHub access_token 長效無 refresh 概念,用 static_key primitive
void upsertAuthRecipe(c.env.RECIPES, {
kind: 'auth_recipe',
service: 'github_user',
version: 1,
primitive: 'static_key',
base_url: 'https://api.github.com',
display_name: 'GitHub(用戶帳號)',
required_secrets: [{ key: 'github_access_token', label: 'GitHub Access Token' }],
inject: { header: { Authorization: 'Bearer {{secret.github_access_token}}' } },
});
}
}
// Upsert user record
const userKey = `user:${provider}:${providerId}`;
const existing = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
let apiKey: string;
if (existing && !existing.revoked) {
// Existing user — keep their api key
apiKey = existing.api_key;
// Update display info
const updated: UserRecord = { ...existing, display_name: displayName, avatar_url: avatarUrl };
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
} else {
// New user — generate a random api key
apiKey = generateApiKey();
const newUser: UserRecord = {
email, display_name: displayName, avatar_url: avatarUrl,
api_key: apiKey, provider, provider_id: providerId,
created_at: new Date().toISOString(),
};
await c.env.USERS_KV.put(userKey, JSON.stringify(newUser));
// Reverse index for API-Key-based auth
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
}
// provider token → credential 新家(Workers Secrets + D1)。登入本身不該因為存
// credential 失敗而失敗(例如 self-hosted 未設 CF_SECRETS_API_TOKEN)→ 吞錯誤只記 log。
if (pendingCredential) {
try {
await storeCredential(c.env, apiKey, pendingCredential.name, pendingCredential.value, pendingCredential.service);
} catch (e) {
console.error('存 provider token 失敗(不影響登入):', e instanceof Error ? e.message : String(e));
}
}
// Create session (TTL 7 days)
const sessionId = randomToken(32);
const session: SessionRecord = {
user_key: userKey,
api_key: apiKey,
email,
expires_at: Date.now() + 7 * 24 * 60 * 60 * 1000,
};
await c.env.SESSIONS_KV.put(`sess:${sessionId}`, JSON.stringify(session), {
expirationTtl: 7 * 24 * 60 * 60,
});
const redirectBack = stateRecord.redirect_back.startsWith('/') ? stateRecord.redirect_back : '/dashboard';
return new Response(null, {
status: 302,
headers: {
Location: `${landingOrigin}${redirectBack}`,
'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=${7 * 24 * 60 * 60}`,
},
});
} catch (err) {
console.error('[auth/callback]', err);
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
}
});
// ─── Logout ───────────────────────────────────────────────────────────────────
authRouter.post('/auth/logout', async (c) => {
const sessId = getSessionId(c.req.raw);
if (sessId) {
await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
}
const landingOrigin = getLandingOrigin(c);
return new Response(null, {
status: 302,
headers: {
Location: `${landingOrigin}/`,
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
},
});
});
// ─── /me ──────────────────────────────────────────────────────────────────────
authRouter.get('/me', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
return c.json({
email: user.email,
display_name: user.display_name,
avatar_url: user.avatar_url,
api_key: user.api_key,
provider: user.provider,
created_at: user.created_at,
});
});
// ─── Rotate API Key ───────────────────────────────────────────────────────────
authRouter.put('/me/api-key/rotate', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
// Generate new random key (not HMAC — rotated keys are random)
const newRaw = randomToken(24);
const newKey = 'ak_' + newRaw;
const oldKey = user.api_key;
const userKey = `user:${user.provider}:${user.provider_id}`;
const updated: UserRecord = { ...user, api_key: newKey };
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
// Update reverse index
await c.env.USERS_KV.delete(`apikey:${oldKey}`);
await c.env.USERS_KV.put(`apikey:${newKey}`, userKey);
return c.json({
success: true,
api_key: newKey,
message: 'API Key rotated. Your existing workflow credentials are still stored under the old key namespace.',
});
});
// ─── Revoke API Key ───────────────────────────────────────────────────────────
authRouter.delete('/me/api-key', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
const userKey = `user:${user.provider}:${user.provider_id}`;
const revoked: UserRecord = { ...user, revoked: true };
await c.env.USERS_KV.put(userKey, JSON.stringify(revoked));
await c.env.USERS_KV.delete(`apikey:${user.api_key}`);
// Clear session cookie
const sessId = getSessionId(c.req.raw);
if (sessId) await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
return new Response(JSON.stringify({ success: true, message: 'API Key revoked.' }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
},
});
});