/** * Cloudflare OAuth 路由(docs/02): * GET /api/oauth/cloudflare/start?node_id= 生成 state,返回授权跳转 URL * GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动安装 * POST /api/oauth/cloudflare/sim/approve sim 模式模拟用户授权(仅本地) * GET /api/oauth/connections 已建立的 OAuth 连接 * * 安全:state 一次性(用后即删)、5 分钟过期、绑定 node_id;token AES-GCM 加密入库, * 不进前端与日志;Account ownership 以 token 实际可访问的 accounts 为准。 */ import { Hono } from "hono"; import type { Env, AppVars } from "../env.ts"; import { requireSession } from "../middleware.ts"; import { aesEncrypt, deriveAesKey, ids } from "../../../shared/src/index.ts"; import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts"; import { provisionNode } from "../services/provision.ts"; import { getSetting } from "../db.ts"; import { nowIso, readJson } from "../util.ts"; type App = Hono<{ Bindings: Env; Variables: AppVars }>; /** # 成员变量: OAuth state 有效期(秒) **/ const STATE_TTL_SECONDS = 300; /** # 函数 * 是否使用真实 Cloudflare OAuth */ export function isRealOAuth(env: Env): boolean { return Boolean(env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET && !env.WMAILHUB_SIM_CF); } /** # 函数 * 装配 OAuth 路由 */ export function oauthRoutes(): App { const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); app.use("*", requireSession); /** 授权模式与入口信息(前端 Add Node 向导用) */ app.get("/mode", (c) => { return c.json({ mode: isRealOAuth(c.env) ? "cloudflare" : "sim", authorize_url_base: isRealOAuth(c.env) ? CF_OAUTH_AUTHORIZE_URL : null, }); }); /** 开始授权:创建 state 并返回跳转 URL */ app.get("/cloudflare/start", async (c) => { const nodeId = new URL(c.req.url).searchParams.get("node_id") ?? ""; const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status = 'pending_oauth' OR id = ?") .bind(nodeId, nodeId) .first<{ id: string }>(); if (!node) return c.json({ error: "node not found" }, 404); const state = "cfstate_" + ids.oauthState(); await c.env.DB.prepare( "INSERT INTO oauth_states (state, node_id, created_at, expires_at) VALUES (?, ?, ?, ?)", ) .bind(state, nodeId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString()) .run(); if (isRealOAuth(c.env)) { const base = c.env.PUBLIC_BASE_URL ?? new URL(c.req.url).origin; const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`; const url = `${CF_OAUTH_AUTHORIZE_URL}?response_type=code` + `&client_id=${encodeURIComponent(c.env.CF_OAUTH_CLIENT_ID!)}` + `&redirect_uri=${encodeURIComponent(redirectUri)}` + `&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` + `&state=${encodeURIComponent(state)}`; return c.json({ url, state }); } // sim 模式:前端内置模拟授权页 return c.json({ url: `/oauth/sim/authorize?state=${encodeURIComponent(state)}`, state }); }); /** 授权回调(浏览器重垒):校验 state -> 兑换 token -> 自动安装 -> 跳回控制台 */ app.get("/cloudflare/callback", async (c) => { const url = new URL(c.req.url); const code = url.searchParams.get("code") ?? ""; const state = url.searchParams.get("state") ?? ""; const verdict = await consumeState(c.env, state); if (!verdict) { return c.text("OAuth state invalid or expired (possible CSRF)", 400); } const cf = getCFApi(c.env, c.env.DB); try { const base = c.env.PUBLIC_BASE_URL ?? url.origin; const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`; const token = await cf.exchangeToken(code, redirectUri); // 加密入库(不进前端/日志) await saveConnection(c.env, token.accessToken, token.expiresIn); const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken }); const target = `/nodes?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`; return c.redirect(target, 302); } catch (err) { return c.text(`OAuth provisioning failed: ${err instanceof Error ? err.message : String(err)}`, 500); } }); /** sim 模式:模拟授权页的确认动作(本地开发用,需会话) */ app.post("/cloudflare/sim/approve", async (c) => { const body = await readJson<{ state?: string }>(c.req.raw); const verdict = await consumeState(c.env, body.state ?? ""); if (!verdict) return c.json({ error: "state invalid or expired" }, 400); const cf = getCFApi(c.env, c.env.DB); const token = await cf.exchangeToken("sim-code", "urn:unused"); await saveConnection(c.env, token.accessToken, token.expiresIn); const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken }); return c.json({ ok: result.ok, error: result.error, node_id: verdict.nodeId }); }); /** 已有连接列表(不含 token) */ app.get("/connections", async (c) => { const rows = await c.env.DB.prepare( "SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC", ).all(); return c.json({ items: rows.results }); }); return app; } /** # 函数 * 消费一次性 state:存在且未过期则删除并返回 node_id,否则 null(CSRF 防护核心) */ async function consumeState( env: Env, state: string, ): Promise<{ nodeId: string } | null> { if (!state) return null; const row = await env.DB.prepare("SELECT node_id, expires_at FROM oauth_states WHERE state = ?") .bind(state) .first<{ node_id: string; expires_at: string }>(); if (!row) return null; await env.DB.prepare("DELETE FROM oauth_states WHERE state = ?").bind(state).run(); if (row.expires_at < nowIso()) return null; return { nodeId: row.node_id }; } /** # 函数 * OAuth access token 加密入库 */ async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise { const key = await deriveAesKey(await getSetting(env, "app_secret") ?? "wmailhub"); const encrypted = await aesEncrypt(key, accessToken); await env.DB.prepare( `INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at) VALUES (?, 'cloudflare', NULL, ?, NULL, ?, ?, ?)`, ) .bind(ids.oauth(), encrypted, new Date(Date.now() + expiresIn * 1000).toISOString(), nowIso(), nowIso()) .run(); }