Files
Arcrun/cypher-executor/src/routes/auth.ts
T
uncle6me-web 922a57fe34 arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。

此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在
richblack/arcrun 與本地 backup 分支)。含:
- acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe)
- recipe push 把關(資料外流提醒 + 打通檢查)
- 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1)
- CLI / cypher-executor / registry / 完整 SDD

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:52:38 +08:00

476 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';
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 KeyHMAC-SHA256 of email,與 /register 相同邏輯) */
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
const msgData = new TextEncoder().encode(email);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
return 'ak_' + hex.slice(0, 32);
}
/** AES-GCM 加密,回傳 {encrypted, iv}base64),與 SDK 格式相同 */
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plaintext));
const toB64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
return { encrypted: toB64(enc), iv: toB64(iv) };
}
/** 幂等寫入 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}`);
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey) {
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
}
try {
let email: string;
let displayName: string;
let avatarUrl: string | undefined;
let providerId: string;
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(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// Google 只在首次授權時回傳 refresh_token,後續登入 tokenData.refresh_token 為 undefined
if (tokenData.refresh_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:google_refresh_token`;
const encrypted = await aesEncrypt(tokenData.refresh_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
// 種 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(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// GitHub 沒有 refresh_tokenaccess_token 長效(直到 revoke
if (tokenData.access_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:github_access_token`;
const encrypted = await aesEncrypt(tokenData.access_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
// 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 api key (same HMAC logic as /register)
apiKey = await generateApiKey(email, encryptionKey);
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);
}
// 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',
},
});
});