/** * Cloudflare OAuth 路由(docs/02、docs/11 §14): * GET /api/oauth/cloudflare/start 生成 state,返回授权跳转 URL(自动创建账号) * GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动接入 * GET /api/oauth/mode OAuth 是否已配置 * GET /api/oauth/cloudflare/preflight 授权配置自检(只读探测 Cloudflare 授权端点) * GET /api/oauth/connections 已建立的 OAuth 连接 * * 流程(Connect Cloudflare Account):Console -> Cloudflare OAuth -> 用户授权 -> * Console 保存 Account + 域名同步 + 部署 Email Worker + 配置 Worker Secret;用户无需安装命令。 * * 安全:state 一次性(用后即删)、5 分钟过期、绑定账号;token AES-GCM 加密入库, * 不进前端与日志;Account ownership 以 token 实际可访问的 accounts 为准。 * 授权回调不校验会话:浏览器从 dash.cloudflare.com 跨站重定向回来时 * SameSite=Strict 的会话 cookie 不会携带,该端点由一次性 state 承担 CSRF 防护。 */ import { Hono } from "hono"; import type { Env, AppVars } from "../env.ts"; import { requireSession } from "../middleware.ts"; import { ids } from "../../../shared/src/index.ts"; import { CF_OAUTH_AUTHORIZE_URL, buildAuthorizeUrl, getCFApi, getOAuthClient, getOAuthScopes, oauthRedirectUri, preflightOAuth, } from "../services/cfapi.ts"; import { provisionAccount, createAccount } from "../services/provision.ts"; import { encryptConnectionToken } from "../db.ts"; import { nowIso } from "../util.ts"; type App = Hono<{ Bindings: Env; Variables: AppVars }>; /** # 成员变量: OAuth state 有效期(秒) **/ const STATE_TTL_SECONDS = 300; /** # 函数 * 装配 OAuth 路由 */ export function oauthRoutes(): App { const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); app.use("*", async (c, next) => { // 授权回调是浏览器从 Cloudflare 跨站重定向回来的请求,会话 cookie 不会带上, // 因此该端点跳过会话校验,改由一次性 state 校验防护(docs/08)。 // 注意:子应用里 c.req.path 是挂载后的完整路径(/api/oauth/...),不能直接等值比较。 if (c.req.path.endsWith("/cloudflare/callback")) return next(); return requireSession(c, next); }); /** 授权模式与入口信息(前端 Connect Cloudflare Account 向导用) */ app.get("/mode", async (c) => { const client = await getOAuthClient(c.env); return c.json({ mode: client ? "cloudflare" : "unconfigured", authorize_url_base: client ? CF_OAUTH_AUTHORIZE_URL : null, source: client?.source ?? "none", scope: await getOAuthScopes(c.env), }); }); /** 授权配置自检:不打开浏览器也能确认 OAuth App 是否可用(见 cfapi.preflightOAuth) */ app.get("/cloudflare/preflight", async (c) => { const result = await preflightOAuth(c.env, new URL(c.req.url).origin); return c.json(result, result.ok ? 200 : 200); }); /** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */ app.get("/cloudflare/start", async (c) => { const client = await getOAuthClient(c.env); if (!client) { return c.json({ error: "cloudflare oauth not configured (在设置 → Cloudflare 接入 中填写 Client ID / Secret)" }, 503); } const created = await createAccount(c.env, { name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`, }); const state = "cfstate_" + ids.oauthState(); await c.env.DB.prepare( "INSERT INTO oauth_states (state, account_id, created_at, expires_at) VALUES (?, ?, ?, ?)", ) .bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString()) .run(); const url = buildAuthorizeUrl({ clientId: client.clientId, redirectUri: oauthRedirectUri(c.env, new URL(c.req.url).origin), scopes: await getOAuthScopes(c.env), state, }); return c.json({ url, state }); }); /** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */ app.get("/cloudflare/callback", async (c) => { if (!(await getOAuthClient(c.env))) { return c.text("Cloudflare OAuth not configured", 503); } 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); } // 授权被 Cloudflare 拒绝(invalid_scope / access_denied ...):不兑换 token, // 直接回控制台并带上错误信息,避免用户停在回调地址上看一个空壳页面。 const oauthError = url.searchParams.get("error"); if (oauthError) { return c.redirect( `/accounts?oauth_error=${encodeURIComponent(oauthError)}` + `&oauth_error_description=${encodeURIComponent(url.searchParams.get("error_description") ?? "")}` + `&account=${encodeURIComponent(verdict.accountId)}`, 302, ); } const cf = await getCFApi(c.env); try { const redirectUri = oauthRedirectUri(c.env, url.origin); const token = await cf.exchangeToken(code, redirectUri); // 加密入库(不进前端/日志) await saveConnection(c.env, token.accessToken, token.expiresIn); const result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken }); const target = `/accounts?provision=${encodeURIComponent(verdict.accountId)}&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); } }); /** 已有连接列表(不含 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:存在且未过期则删除并返回 account_id,否则 null(CSRF 防护核心) */ async function consumeState( env: Env, state: string, ): Promise<{ accountId: string } | null> { if (!state) return null; const row = await env.DB.prepare("SELECT account_id, expires_at FROM oauth_states WHERE state = ?") .bind(state) .first<{ account_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 { accountId: row.account_id }; } /** # 函数 * OAuth access token 加密入库 */ async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise { const encrypted = await encryptConnectionToken(env, 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(); }