922a57fe34
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>
476 lines
19 KiB
TypeScript
476 lines
19 KiB
TypeScript
// 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 Key(HMAC-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 Key(X-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_token,access_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',
|
||
},
|
||
});
|
||
});
|