// 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 { 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): Promise { 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 { const sessId = getSessionId(c.req.raw); if (sessId) { const sess = await c.env.SESSIONS_KV.get(`sess:${sessId}`, 'json'); if (sess && sess.expires_at > Date.now()) { const user = await c.env.USERS_KV.get(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(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(`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(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', }, }); });