diff --git a/README.md b/README.md index 688251e..34caf30 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,18 @@ curl -X POST http://127.0.0.1:18789/__email_test \ # 不存在的邮箱:{"action":"rejected","reason":"Mailbox does not exist","code":"MAILBOX_NOT_FOUND"} ``` -`bun run dev` 首次运行会自动:Connect Cloudflare Account(sim 模式 OAuth)→ 同步账号下全部域名 + catch-all 邮箱 → 逐域名配置 Email Workers(部署账号级 Worker + Email Routing + catch-all 规则)→ 签发 WorkerCredential → 启动本地 Email Worker 直连 Console。全程无需复制任何安装命令。 +`bun run dev` 启动 Console(:18787)与 Email Worker(:18789)。Cloudflare OAuth 没有模拟通道:账号必须在 Console 页面里人工授权接入。因此首次运行若本地还没有账号,脚本只拉起 Console 并提示下一步;接入成功后再次执行 `bun run dev`,就会自动为未激活域名配置 Email Workers(部署账号级 Worker + Email Routing + catch-all 规则)、签发 WorkerCredential 并拉起本地 Email Worker。 + +接入前先配置 Cloudflare OAuth 客户端(本地同样必需): + +```sh +# .env 或 shell 环境 +export CF_OAUTH_CLIENT_ID=... +export CF_OAUTH_CLIENT_SECRET=... +export CF_OAUTH_REDIRECT_URI=http://127.0.0.1:18787/api/oauth/cloudflare/callback +``` + +未配置时账号页「添加账号」返回 503 并提示原因,不会生成任何假账号或假资源。 ## 功能 @@ -112,7 +123,7 @@ WmailHub/ 3. 依次执行 `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql` … `0004_single_key_markdown.sql`(0001~0004 按序号全部执行,0004 依赖 0001 的 api_credentials) 4. `cd console && bunx wrangler deploy`(前端先 `bun run build`);`PUBLIC_BASE_URL` 即 Email Worker 注入的 CONSOLE_URL 5. `bunx wrangler secret put SESSION_SECRET --config console/wrangler.jsonc`(≥32 位随机串) -6. Cloudflare Dashboard 创建 OAuth App(client id/secret),配置 `CF_OAUTH_CLIENT_ID/SECRET` vars 与回调 `https://wmailhub.adx9.com/api/oauth/cloudflare/callback`;未配置时自动进入 sim 模式(仅供本地) +6. Cloudflare Dashboard 创建 OAuth App(client id/secret),配置 `CF_OAUTH_CLIENT_ID/SECRET` vars 与回调 `https://wmailhub.adx9.com/api/oauth/cloudflare/callback`。**这是接入 Cloudflare 账号的硬前提**:未配置时 `/api/oauth/cloudflare/start` 与 `/cloudflare/callback` 直接返回 503,系统不会降级到任何模拟通道 > 提示:`wrangler.jsonc` 里的 `database_id` 必须先填真实值,否则 `d1 execute` / `deploy` 都会报 `Invalid uuid`。 > 部署后自检:`curl https://wmailhub.adx9.com/api/health` → `{"ok":true,"name":"WmailHub","version":""}`。 diff --git a/console/frontend/src/api.ts b/console/frontend/src/api.ts index 6b08d27..0d54ca6 100644 --- a/console/frontend/src/api.ts +++ b/console/frontend/src/api.ts @@ -153,14 +153,13 @@ export const getAccountWorkerSecret = (id: string) => /* ---- OAuth ---- */ -export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode"); +/** OAuth 是否已配置(cloudflare = 可用;unconfigured = 缺少 client id/secret) */ +export const oauthMode = () => + api<{ mode: "cloudflare" | "unconfigured"; authorize_url_base: string | null }>("/api/oauth/mode"); /** 开始授权(自动创建账号记录;授权后自动接入) */ export const oauthStart = () => api<{ url: string; state: string }>("/api/oauth/cloudflare/start"); -export const oauthSimApprove = (state: string) => - api<{ ok: boolean; error?: string; account_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state }); - /* ---- 域名 ---- */ export interface DomainRow { diff --git a/console/frontend/src/main.tsx b/console/frontend/src/main.tsx index 2f0fbf4..2c86f76 100644 --- a/console/frontend/src/main.tsx +++ b/console/frontend/src/main.tsx @@ -9,7 +9,6 @@ import { ThemeContext } from "./theme-context.ts"; import "./styles.css"; import { App } from "./App.tsx"; import { LoginPage } from "./pages/Login.tsx"; -import { OAuthSimPage } from "./pages/OAuthSim.tsx"; import { DashboardPage } from "./pages/Dashboard.tsx"; import { AccountsPage } from "./pages/Accounts.tsx"; import { DomainsPage } from "./pages/Domains.tsx"; @@ -20,7 +19,6 @@ import { setUnauthorizedHandler } from "./api.ts"; /** # 成员变量: 路由表(邮件页路由 /inboxs,v2.1.0) **/ const router = createBrowserRouter([ { path: "/login", element: }, - { path: "/oauth/sim/authorize", element: }, { path: "/", element: , diff --git a/console/frontend/src/pages/Accounts.tsx b/console/frontend/src/pages/Accounts.tsx index c1d1b6b..f6cfebb 100644 --- a/console/frontend/src/pages/Accounts.tsx +++ b/console/frontend/src/pages/Accounts.tsx @@ -1,6 +1,6 @@ /** 账号页:添加账号直达 Cloudflare OAuth,授权后自动接入并同步域名;搜索、详情、Worker 凭据管理 */ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; import { Button, Card, @@ -18,7 +18,6 @@ import { deleteAccount, getAccount, listAccounts, - oauthMode, oauthStart, rotateAccount, updateAccount, @@ -47,7 +46,6 @@ const STEP_LABEL: Record = { */ export function AccountsPage() { const { message, modal } = App.useApp(); - const navigate = useNavigate(); const [params] = useSearchParams(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); @@ -90,13 +88,11 @@ export function AccountsPage() { [items, q], ); - /** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页 */ + /** 发起 OAuth:整页跳转到 Cloudflare 授权页(授权完成后浏览器重定向回 Console 回调端点) */ const startOAuth = async () => { try { - const mode = (await oauthMode()).mode; const { url } = await oauthStart(); - if (mode === "cloudflare") window.location.href = url; - else navigate(url); // /oauth/sim/authorize?state=... + window.location.href = url; } catch (err) { message.error(err instanceof Error ? err.message : "发起授权失败"); } diff --git a/console/frontend/src/pages/OAuthSim.tsx b/console/frontend/src/pages/OAuthSim.tsx deleted file mode 100644 index 42bfdd9..0000000 --- a/console/frontend/src/pages/OAuthSim.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** sim 模式的 Cloudflare 授权模拟页:确认后调用 approve,再跳回节点页 */ -import { useEffect, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import { Button, Card, Space, App } from "antd"; -import { oauthSimApprove } from "../api.ts"; - -/** # 函数 - * 模拟授权页(仅本地 sim 模式;真实部署直接跳 Cloudflare 授权页) - */ -export function OAuthSimPage() { - const [params] = useSearchParams(); - const navigate = useNavigate(); - const { message } = App.useApp(); - const state = params.get("state") ?? ""; - const [busy, setBusy] = useState(false); - - const approve = async () => { - setBusy(true); - try { - const res = await oauthSimApprove(state); - if (res.ok) { - message.success("Cloudflare 授权与自动安装完成"); - navigate(`/accounts?provision=${encodeURIComponent(res.account_id)}`, { replace: true }); - } else { - message.error(res.error ?? "安装失败"); - navigate("/accounts", { replace: true }); - } - } catch (err) { - message.error(err instanceof Error ? err.message : "授权失败"); - navigate("/accounts", { replace: true }); - } finally { - setBusy(false); - } - }; - - // state 缺失直接回节点页 - useEffect(() => { - if (!state) navigate("/accounts", { replace: true }); - }, [state, navigate]); - - return ( -
- -

sim 模式下,该页面模拟 Cloudflare OAuth 授权同意流程。

-

真实部署时,这里会跳转到 dash.cloudflare.com 的授权页面。

- - - - -
-
- ); -} diff --git a/console/src/env.ts b/console/src/env.ts index 1b29766..69a44c1 100644 --- a/console/src/env.ts +++ b/console/src/env.ts @@ -53,8 +53,6 @@ export interface Env { CF_OAUTH_CLIENT_ID?: string; CF_OAUTH_CLIENT_SECRET?: string; CF_OAUTH_REDIRECT_URI?: string; - /** 强制使用 sim 模式("1") **/ - WMAILHUB_SIM_CF?: string; /** 本地联调:允许 webhook endpoint 指向私网/localhost("1"),生产严禁开启 **/ WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string; } diff --git a/console/src/local/main.ts b/console/src/local/main.ts index 1f91ec4..34dd299 100644 --- a/console/src/local/main.ts +++ b/console/src/local/main.ts @@ -50,9 +50,17 @@ const env: Env = { R2: new LocalR2(path.join(DATA_DIR, "r2")), SESSION_SECRET: process.env.WMAILHUB_SESSION_SECRET, PUBLIC_BASE_URL: process.env.WMAILHUB_PUBLIC_BASE_URL ?? `http://127.0.0.1:${PORT}`, - WMAILHUB_SIM_CF: process.env.WMAILHUB_SIM_CF ?? "1", + // 真实 Cloudflare OAuth 客户端(无模拟模式兜底,缺失时接入接口返回 503) + CF_OAUTH_CLIENT_ID: process.env.CF_OAUTH_CLIENT_ID, + CF_OAUTH_CLIENT_SECRET: process.env.CF_OAUTH_CLIENT_SECRET, + CF_OAUTH_REDIRECT_URI: process.env.CF_OAUTH_REDIRECT_URI, WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: process.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS ?? "1", }; +if (!env.CF_OAUTH_CLIENT_ID || !env.CF_OAUTH_CLIENT_SECRET) { + console.warn( + "[console] 未配置 CF_OAUTH_CLIENT_ID / CF_OAUTH_CLIENT_SECRET:账号页「添加账号」将返回 503,无法接入 Cloudflare 账号", + ); +} // ---- 静态资源 ---------------------------------------------------------------- const MIME: Record = { diff --git a/console/src/routes/oauth.ts b/console/src/routes/oauth.ts index 0ef612c..3e35645 100644 --- a/console/src/routes/oauth.ts +++ b/console/src/routes/oauth.ts @@ -2,7 +2,7 @@ * Cloudflare OAuth 路由(docs/02、docs/11 §14): * GET /api/oauth/cloudflare/start 生成 state,返回授权跳转 URL(自动创建账号) * GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动接入 - * POST /api/oauth/cloudflare/sim/approve sim 模式模拟用户授权(仅本地) + * GET /api/oauth/mode OAuth 是否已配置 * GET /api/oauth/connections 已建立的 OAuth 连接 * * 流程(Connect Cloudflare Account):Console -> Cloudflare OAuth -> 用户授权 -> @@ -10,46 +10,51 @@ * * 安全: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, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts"; +import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi, isOAuthConfigured } from "../services/cfapi.ts"; import { provisionAccount, createAccount } from "../services/provision.ts"; import { encryptConnectionToken } from "../db.ts"; -import { nowIso, readJson } from "../util.ts"; +import { nowIso } 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); + 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", (c) => { + const configured = isOAuthConfigured(c.env); return c.json({ - mode: isRealOAuth(c.env) ? "cloudflare" : "sim", - authorize_url_base: isRealOAuth(c.env) ? CF_OAUTH_AUTHORIZE_URL : null, + mode: configured ? "cloudflare" : "unconfigured", + authorize_url_base: configured ? CF_OAUTH_AUTHORIZE_URL : null, }); }); /** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */ app.get("/cloudflare/start", async (c) => { + if (!isOAuthConfigured(c.env)) { + return c.json({ error: "cloudflare oauth not configured (CF_OAUTH_CLIENT_ID/CF_OAUTH_CLIENT_SECRET)" }, 503); + } const created = await createAccount(c.env, { name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`, }); @@ -61,23 +66,22 @@ export function oauthRoutes(): App { .bind(state, created.accountId, 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 }); + 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 }); }); /** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */ app.get("/cloudflare/callback", async (c) => { + if (!isOAuthConfigured(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") ?? ""; @@ -85,7 +89,7 @@ export function oauthRoutes(): App { if (!verdict) { return c.text("OAuth state invalid or expired (possible CSRF)", 400); } - const cf = getCFApi(c.env, c.env.DB); + const cf = getCFApi(c.env); try { const base = c.env.PUBLIC_BASE_URL ?? url.origin; const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`; @@ -100,18 +104,6 @@ export function oauthRoutes(): App { } }); - /** 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 provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken }); - return c.json({ ok: result.ok, error: result.error, account_id: verdict.accountId }); - }); - /** 已有连接列表(不含 token) */ app.get("/connections", async (c) => { const rows = await c.env.DB.prepare( diff --git a/console/src/services/cfapi.ts b/console/src/services/cfapi.ts index 5583dfe..f8cff37 100644 --- a/console/src/services/cfapi.ts +++ b/console/src/services/cfapi.ts @@ -1,25 +1,22 @@ /** - * Cloudflare API 客户端抽象。 + * Cloudflare API 客户端:真实调用 api.cloudflare.com / dash.cloudflare.com。 * - * - RealCFApi:真实调用 api.cloudflare.com / dash.cloudflare.com(OAuth access token) - * - SimCFApi :本地 sim 模式。不访问外网,把"创建"的资源记录到 cf_sim_resources 表, - * 让 Connect Cloudflare Account 自动接入链路在本地完整可演示、可测试。 - * - * Console 通过 env.CF_OAUTH_CLIENT_ID/SECRET 判断模式;两者齐备为 real,否则 sim。 + * OAuth access token 来自 Cloudflare OAuth 授权(docs/02),Console 用它完成 + * 账号/域名发现、Email Worker 部署与 Email Routing 配置。 + * 未配置 CF_OAUTH_CLIENT_ID/SECRET 时不提供任何降级通道—— + * /api/oauth/cloudflare/start 直接返回 503,避免出现"看似接入成功"的假数据。 */ import { renderEmailWorkerScript } from "../../../shared/src/index.ts"; import type { Env } from "../env.ts"; -import { ids } from "../../../shared/src/index.ts"; -import { nowIso } from "../util.ts"; -/** # 成员变量: OAuth 授权端点(real 模式) **/ +/** # 成员变量: OAuth 授权端点 **/ export const CF_OAUTH_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth"; -/** # 成员变量: OAuth token 端点(real 模式) **/ +/** # 成员变量: OAuth token 端点 **/ export const CF_OAUTH_TOKEN_URL = "https://dash.cloudflare.com/oauth2/token"; -/** # 成员变量: Cloudflare API 基址(real 模式) **/ +/** # 成员变量: Cloudflare API 基址 **/ export const CF_API_BASE = "https://api.cloudflare.com/client/v4"; /** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/ @@ -45,7 +42,7 @@ export interface CFZone { /** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/ export interface CFApi { - /** 用 code 换 access token(real);sim 直接签发假 token */ + /** 用 code 换 access token */ exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>; /** 发现 token 可访问的账户 */ listAccounts(accessToken: string): Promise; @@ -74,15 +71,20 @@ export interface CFApi { } /** # 函数 - * 根据环境构造 Cloudflare API 客户端(real 或 sim) + * Cloudflare OAuth 是否已配置(client id + secret 齐备) */ -export function getCFApi(env: Env, db: Env["DB"]): CFApi { - const real = env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET && !env.WMAILHUB_SIM_CF; - if (real) return new RealCFApi(env); - return new SimCFApi(db); +export function isOAuthConfigured(env: Env): boolean { + return Boolean(env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET); } -/** # 成员变量: 真实 Cloudflare API 实现 **/ +/** # 函数 + * 构造 Cloudflare API 客户端 + */ +export function getCFApi(env: Env): CFApi { + return new RealCFApi(env); +} + +/** # 成员变量: Cloudflare API 实现 **/ export class RealCFApi implements CFApi { /** # 成员变量 **/ constructor(private env: Env) {} @@ -186,73 +188,3 @@ export class RealCFApi implements CFApi { return { ok: true }; } } - -/** # 成员变量: 本地 sim 实现:资源写入 cf_sim_resources,行为与真实链路一致 **/ -export class SimCFApi implements CFApi { - /** # 成员变量 **/ - constructor(private db: Env["DB"]) {} - - private async record(kind: string, refId: string, payload: unknown): Promise { - await this.db - .prepare( - "INSERT INTO cf_sim_resources (id, kind, ref_id, payload, created_at) VALUES (?, ?, ?, ?, ?) " + - "ON CONFLICT(id) DO UPDATE SET payload = excluded.payload", - ) - .bind(ids.simResource(), kind, refId, JSON.stringify(payload), nowIso()) - .run(); - } - - async exchangeToken(_code: string, _redirectUri: string) { - return { accessToken: "sim_" + ids.oauth(), expiresIn: 3600 }; - } - - async listAccounts(_token: string): Promise { - // sim 固定一个演示账户;幂等记录 - const account = { id: "sim-account-0001", name: "WmailHub Sim Account" }; - await this.record("account", account.id, account); - return [account]; - } - - async listZones(_token: string, accountId: string): Promise { - // sim 返回多个演示域名,覆盖「OAuth 后自动同步全部域名」的展示 - const zones = [ - { id: "sim-zone-example.test", name: "example.test", account_id: accountId, status: "active" }, - { id: "sim-zone-demo.test", name: "demo.test", account_id: accountId, status: "active" }, - { id: "sim-zone-mailbox.dev", name: "mailbox.dev", account_id: accountId, status: "active" }, - ]; - for (const z of zones) await this.record("zone", z.id, z); - return zones; - } - - async putEmailWorker(opts: { - accessToken: string; - accountId: string; - scriptName: string; - consoleUrl: string; - workerId: string; - workerSecret: string; - }) { - // 与真实实现同一模板渲染,但只记录不外发 - await this.record("email_worker", opts.scriptName, { - account_id: opts.accountId, - console_url: opts.consoleUrl, - worker_id: opts.workerId, - script: renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret).length, - }); - return { id: opts.scriptName }; - } - - async enableEmailRouting(opts: { zoneId: string }) { - await this.record("routing_settings", opts.zoneId, { enabled: true }); - return { enabled: true }; - } - - async setCatchAllRule(opts: { zoneId: string; workerName: string }) { - await this.record("routing_rule", opts.zoneId, { - type: "catch_all", - action: "worker", - value: opts.workerName, - }); - return { ok: true }; - } -} diff --git a/console/src/services/provision.ts b/console/src/services/provision.ts index 35795b8..e401a53 100644 --- a/console/src/services/provision.ts +++ b/console/src/services/provision.ts @@ -109,7 +109,7 @@ export interface ProvisionInput { * 部署账号级 Email Worker + 签发 Worker credential。幂等,可重复调用;返回是否全部成功。 */ export async function provisionAccount(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> { - const cf: CFApi = getCFApi(env, env.DB); + const cf: CFApi = getCFApi(env); const account = await env.DB.prepare("SELECT id, name, status FROM cloudflare_accounts WHERE id = ?") .bind(input.accountId) .first<{ id: string; name: string; status: string }>(); @@ -249,7 +249,7 @@ async function putWorkerScript( workerId: string, workerSecret: string, ): Promise { - const cf: CFApi = getCFApi(env, env.DB); + const cf: CFApi = getCFApi(env); const consoleUrl = env.PUBLIC_BASE_URL ?? ""; if (!consoleUrl) throw new Error("console public url unknown (PUBLIC_BASE_URL)"); await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, workerId, workerSecret }); @@ -278,7 +278,7 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok const accessToken = await decryptConnectionToken(env); if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" }; - const cf: CFApi = getCFApi(env, env.DB); + const cf: CFApi = getCFApi(env); try { const workerId = await deployAccountWorker(env, account.id, accessToken); await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id }); diff --git a/console/wrangler.jsonc b/console/wrangler.jsonc index 95ce80c..827a441 100644 --- a/console/wrangler.jsonc +++ b/console/wrangler.jsonc @@ -4,7 +4,7 @@ // 2. `bunx wrangler r2 bucket create wmailhub` (已完成) // 3. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql` 起逐个执行 0001~0004 // 4. `bunx wrangler secret put SESSION_SECRET` (>=32 位随机串) -// 5. 配置 CF_OAUTH_CLIENT_ID/SECRET/REDIRECT_URI vars(真实 OAuth;缺省走 sim) +// 5. 配置 CF_OAUTH_CLIENT_ID/SECRET/REDIRECT_URI vars(真实 OAuth 硬前提,缺失时接入接口返回 503) { "$schema": "../node_modules/wrangler/config-schema.json", "name": "wmailhub-console", diff --git a/docs/02_CLOUDFLARE_OAUTH.md b/docs/02_CLOUDFLARE_OAUTH.md index b41f0dd..dae14db 100644 --- a/docs/02_CLOUDFLARE_OAUTH.md +++ b/docs/02_CLOUDFLARE_OAUTH.md @@ -29,6 +29,13 @@ Console - Cloudflare credential(OAuth access token)AES-GCM 加密存储,不进入前端和日志。 - OAuth state 一次性(用后即删)、5 分钟过期、绑定账号(CSRF 防护)。 +- **授权回调不校验会话**:浏览器从 `dash.cloudflare.com` 跳回 Console 属于跨站顶级导航,`SameSite=Strict` 的会话 cookie 不会携带;该端点由一次性 state 承担 CSRF 防护,其余 `/api/oauth/*` 仍要求登录。 - Account ownership 以 token 实际可访问的 accounts 为准。 - 安装过程幂等,重复执行不产生重复资源。 - Worker credential 与 Cloudflare credential 分离;轮换 credential 后已配置域名自动重新部署。 + +## 前置条件 + +`CF_OAUTH_CLIENT_ID` 与 `CF_OAUTH_CLIENT_SECRET` 必须配置,否则 `/api/oauth/cloudflare/start` 与 +`/api/oauth/cloudflare/callback` 一律返回 503 —— 系统不提供任何模拟/降级通道, +不会出现"看起来接入成功"的假账号与假资源。 diff --git a/docs/05_DATA_MODEL.md b/docs/05_DATA_MODEL.md index f567a58..0ae0e0d 100644 --- a/docs/05_DATA_MODEL.md +++ b/docs/05_DATA_MODEL.md @@ -79,4 +79,4 @@ status(active | revoked), created_at, revoked_at ## 其它 -users、settings(全局 Webhook、单一 API/MCP Key:api_key_hash/api_key_last4/api_key_created_at)、nonces、oauth_states、cf_sim_resources(本地 sim)。v2.1.0 起 api_credentials 表已删除(Key 合并为单一 Key 存 settings)。 +users、settings(全局 Webhook、单一 API/MCP Key:api_key_hash/api_key_last4/api_key_created_at)、nonces、oauth_states。v2.1.0 起 api_credentials 表已删除(Key 合并为单一 Key 存 settings);v2.2.0 起 cf_sim_resources 表已删除(移除本地 sim 模式,接入链路只有真实 Cloudflare OAuth)。 diff --git a/migrations/0005_drop_sim_resources.sql b/migrations/0005_drop_sim_resources.sql new file mode 100644 index 0000000..7eb75fb --- /dev/null +++ b/migrations/0005_drop_sim_resources.sql @@ -0,0 +1,3 @@ +-- v2.2.0:移除本地 sim 模式,Cloudflare OAuth 只走真实授权。 +-- cf_sim_resources 记录的是「模拟 Cloudflare API 已创建的资源」,接入链路不再有模拟实现,整表废弃。 +DROP TABLE IF EXISTS cf_sim_resources; diff --git a/package.json b/package.json index 3cdb7f6..c471cb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wmailhub", - "version": "2.1.2", + "version": "2.2.0", "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)", "private": true, "type": "module", diff --git a/scripts/dev.ts b/scripts/dev.ts index d25d595..76781ea 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -6,9 +6,9 @@ * 流程: * 1. 构建前端(vite build) * 2. 启动 Console(:18787,自动迁移 + admin/admin 种子) - * 3. 确保存在账号(无则 OAuth sim 自动创建账号),并逐域名配置 Email Workers - * (域名 setup 会部署账号级 Email Worker + 签发 WorkerCredential) - * 4. 启动 Email Worker(:18789,authorize/ingest 直连 Console) + * 3. 已有 OAuth 接入的账号则为其配置未激活域名的 Email Workers + * (域名 setup 会部署账号级 Email Worker + 签发 WorkerCredential),然后启动本地 Email Worker + * 4. 没有账号时只跑 Console:Cloudflare OAuth 需要浏览器人工授权,不再有模拟通道 * 5. Ctrl-C 一起退出 */ @@ -78,62 +78,58 @@ if (!loginResp.ok) throw new Error("admin 登录失败"); const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0]; const authHeaders = { Cookie: cookie, "Content-Type": "application/json" }; -// 3. 确保账号存在(无则走 OAuth 自动创建) +// 3. 账号:OAuth 授权必须由浏览器人工完成,这里只消费已接入的账号 const accountsResp = await fetch(`${BASE}/api/accounts`, { headers: authHeaders }); const existing = ((await accountsResp.json()) as { items: { id: string; name: string }[] }).items; -let accountId: string; -if (existing.length > 0) { - accountId = existing[0].id; - console.log(`[dev] 复用既有账号 ${accountId}`); +if (existing.length === 0) { + console.log(""); + console.log("======== WmailHub 本地 Console 已就绪(尚未接入 Cloudflare 账号)========"); + console.log(` Console: ${BASE} (admin / admin)`); + console.log(` 下一步 : 打开 Console → 账号 → 添加账号,完成 Cloudflare OAuth 授权`); + console.log(` (需先配置 CF_OAUTH_CLIENT_ID / CF_OAUTH_CLIENT_SECRET,并在 Cloudflare`); + console.log(` 登记回调 ${BASE}/api/oauth/cloudflare/callback)`); + console.log(" 接入成功后重新执行 `bun run dev` 即可一并拉起本地 Email Worker"); + console.log("================================================================"); + console.log(""); } else { - // 添加账号:OAuth start 自动创建账号,授权后自动接入 - const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start`, { headers: authHeaders }); - const { state } = (await startResp.json()) as { state: string }; - const approve = await fetch(`${BASE}/api/oauth/cloudflare/sim/approve`, { - method: "POST", - headers: authHeaders, - body: JSON.stringify({ state }), + const accountId = existing[0].id; + console.log(`[dev] 使用已接入账号 ${accountId}`); + + // 4. 域名同步后逐域名配置 Email Workers(部署 Worker + Routing + catch-all;幂等) + const domainsResp = await fetch(`${BASE}/api/domains`, { headers: authHeaders }); + const domains = ((await domainsResp.json()) as { items: { id: string; name: string; account_id: string | null; status: string }[] }).items; + for (const d of domains.filter((x) => x.account_id === accountId && x.status !== "active")) { + const setup = (await ( + await fetch(`${BASE}/api/domains/${d.id}/setup`, { method: "POST", headers: authHeaders }) + ).json()) as { ok: boolean; error?: string }; + if (!setup.ok) throw new Error(`域名 ${d.name} 配置 Email Workers 失败:${setup.error}`); + console.log(`[dev] 域名 ${d.name} Email Workers 已配置`); + } + + // 5. Email Worker(authorize/ingest 直连 Console;凭据来自 WorkerCredential) + const secretResp = (await ( + await fetch(`${BASE}/api/accounts/${accountId}/worker-secret`, { headers: authHeaders }) + ).json()) as { worker_id: string; secret: string }; + spawn("worker", ["worker/src/local.ts"], ROOT, { + CONSOLE_URL: BASE, + WORKER_ID: secretResp.worker_id, + WORKER_SECRET: secretResp.secret, + WMAILHUB_WORKER_PORT: String(WORKER_PORT), }); - const approveRes = (await approve.json()) as { ok: boolean; error?: string; account_id: string }; - if (!approveRes.ok) throw new Error(`自动创建账号失败:${approveRes.error}`); - accountId = approveRes.account_id; - console.log(`[dev] OAuth(sim)已创建账号 ${accountId} 并同步域名`); + await waitReady(`http://127.0.0.1:${WORKER_PORT}/__health`, "email worker"); + console.log("[dev] email worker ready"); + + console.log(""); + console.log("======== WmailHub 本地环境已就绪 ========"); + console.log(` Console : ${BASE} (admin / admin)`); + console.log(` Email Worker: http://127.0.0.1:${WORKER_PORT}/__health (worker_id=${secretResp.worker_id})`); + console.log(" 发测试邮件 : curl -X POST http://127.0.0.1:18789/__email_test \\"); + console.log(" -H 'X-WmailHub-Test-Secret: ' \\"); + console.log(" -H 'X-WmailHub-Test-To: hello@example.test' --data-binary @mail.eml"); + console.log("=========================================="); } -// 4. 域名同步后逐域名配置 Email Workers(部署 Worker + Routing + catch-all;幂等) -const domainsResp = await fetch(`${BASE}/api/domains`, { headers: authHeaders }); -const domains = ((await domainsResp.json()) as { items: { id: string; name: string; account_id: string | null; status: string }[] }).items; -for (const d of domains.filter((x) => x.account_id === accountId && x.status !== "active")) { - const setup = (await ( - await fetch(`${BASE}/api/domains/${d.id}/setup`, { method: "POST", headers: authHeaders }) - ).json()) as { ok: boolean; error?: string }; - if (!setup.ok) throw new Error(`域名 ${d.name} 配置 Email Workers 失败:${setup.error}`); - console.log(`[dev] 域名 ${d.name} Email Workers 已配置`); -} - -// 5. Email Worker(authorize/ingest 直连 Console;凭据来自 WorkerCredential) -const secretResp = (await ( - await fetch(`${BASE}/api/accounts/${accountId}/worker-secret`, { headers: authHeaders }) -).json()) as { worker_id: string; secret: string }; -spawn("worker", ["worker/src/local.ts"], ROOT, { - CONSOLE_URL: BASE, - WORKER_ID: secretResp.worker_id, - WORKER_SECRET: secretResp.secret, - WMAILHUB_WORKER_PORT: String(WORKER_PORT), -}); -await waitReady(`http://127.0.0.1:${WORKER_PORT}/__health`, "email worker"); -console.log("[dev] email worker ready"); - -console.log(""); -console.log("======== WmailHub 本地环境已就绪 ========"); -console.log(` Console : ${BASE} (admin / admin)`); -console.log(` Email Worker: http://127.0.0.1:${WORKER_PORT}/__health (worker_id=${secretResp.worker_id})`); -console.log(" 发测试邮件 : curl -X POST http://127.0.0.1:18789/__email_test \\"); -console.log(" -H 'X-WmailHub-Test-Secret: ' \\"); -console.log(" -H 'X-WmailHub-Test-To: hello@example.test' --data-binary @mail.eml"); -console.log("=========================================="); - /** 退出时统一收割子进程 */ function shutdown() { for (const { name, proc } of children) { diff --git a/shared/src/ids.ts b/shared/src/ids.ts index 50c000a..6d43fd3 100644 --- a/shared/src/ids.ts +++ b/shared/src/ids.ts @@ -33,5 +33,4 @@ export const ids = { apiKey: () => newId("key"), oauthState: () => newId("st"), credential: () => newId("cred"), - simResource: () => newId("sim"), }; diff --git a/tests/cf-mock.ts b/tests/cf-mock.ts new file mode 100644 index 0000000..5da4411 --- /dev/null +++ b/tests/cf-mock.ts @@ -0,0 +1,122 @@ +/** + * Cloudflare API 抓包桩(仅测试用): + * 拦截 globalThis.fetch,对 dash.cloudflare.com / api.cloudflare.com 返回固定响应, + * 并记录真实发出的 HTTP 调用。生产代码里没有任何模拟实现——接入链路跑的始终是 + * RealCFApi,测试只是在传输层把它要访问的 Cloudflare 换成本地桩。 + */ + +/** # 成员变量: 记录到的 Cloudflare API 调用 **/ +export interface CfCall { + /** 请求方法 **/ + method: string; + /** 完整 URL **/ + url: string; + /** 路径(去掉 base) **/ + path: string; + /** Authorization 头(OAuth Bearer token) **/ + authorization: string | null; + /** 请求体文本(URL 编码或 JSON);multipart 上传记 null **/ + body: string | null; +} + +/** # 成员变量: 桩的返回数据配置 **/ +export interface CfMockOptions { + /** /accounts 返回的账户列表 **/ + accounts?: { id: string; name: string }[]; + /** /zones 返回的域名列表 **/ + zones?: { id: string; name: string; account_id: string; status: string }[]; + /** OAuth token 端点签发的 access token **/ + accessToken?: string; + /** token 端点 HTTP 状态(测失败分支用) **/ + tokenStatus?: number; +} + +/** # 成员变量: Cloudflare API 桩句柄 **/ +export interface CfMock { + /** 已记录的调用 **/ + calls: CfCall[]; + /** 恢复原始 fetch **/ + restore(): void; + /** 是否发生过指定 method + 路径子串的调用 **/ + hit(method: string, pathPart: string): boolean; + /** 取出第一条匹配调用的 body **/ + bodyOf(method: string, pathPart: string): string | null; +} + +const TOKEN_URL = "https://dash.cloudflare.com/oauth2/token"; +const API_BASE = "https://api.cloudflare.com/client/v4"; + +/** # 函数 + * 安装 Cloudflare API 桩:接管 globalThis.fetch,返回记录与恢复句柄 + * 参数: opts.accounts/zones 决定发现到的资源,tokenStatus 可模拟 token 兑换失败 + */ +export function installCloudflareMock(opts: CfMockOptions = {}): CfMock { + const accounts = opts.accounts ?? [{ id: "cf-acct-real-1", name: "Real Cloudflare Account" }]; + const zones = opts.zones ?? [ + { id: "zone-example", name: "example.com", account_id: accounts[0]?.id ?? "", status: "active" }, + { id: "zone-demo", name: "demo.com", account_id: accounts[0]?.id ?? "", status: "active" }, + ]; + const accessToken = opts.accessToken ?? "cf-oauth-access-token-xyz"; + const calls: CfCall[] = []; + const original = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ); + const rawBody = init?.body; + const body = + typeof rawBody === "string" + ? rawBody + : rawBody instanceof URLSearchParams + ? rawBody.toString() + : null; + + calls.push({ + method, + url, + path: url.startsWith(API_BASE) ? url.slice(API_BASE.length) : url, + authorization: headers.get("authorization"), + // multipart 上传(Email Worker 脚本)读不出文本,只记方法与路径 + body: rawBody instanceof FormData ? null : body, + }); + + const json = (result: unknown, status = 200) => + new Response(JSON.stringify({ success: true, errors: [], messages: [], result }), { + status, + headers: { "Content-Type": "application/json" }, + }); + + if (url === TOKEN_URL) { + if ((opts.tokenStatus ?? 200) !== 200) { + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: opts.tokenStatus }); + } + // OAuth token 端点是裸 JSON(不是 Cloudflare API 的 {success,result} 信封) + return new Response(JSON.stringify({ access_token: accessToken, expires_in: 3600, token_type: "bearer" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.startsWith(`${API_BASE}/accounts`)) return json(accounts); + if (url.startsWith(`${API_BASE}/zones`)) { + if (url.includes("/email/routing/rules/catch_all")) return json({ id: "catch-all-rule" }); + if (url.includes("/email/routing")) return json({ enabled: true }); + return json(zones); + } + if (url.includes("/workers/scripts/")) return json({ id: url.split("/").pop() }); + return json(null, 404); + }) as typeof globalThis.fetch; + + return { + calls, + restore: () => { + globalThis.fetch = original; + }, + hit: (method, pathPart) => + calls.some((c) => c.method === method.toUpperCase() && c.path.includes(pathPart)), + bodyOf: (method, pathPart) => + calls.find((c) => c.method === method.toUpperCase() && c.path.includes(pathPart))?.body ?? null, + }; +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 74d4f65..6fd0463 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -75,7 +75,9 @@ export async function makeTestEnv(): Promise { DB: new LocalD1(db), R2: new LocalR2(mkdtempSync(path.join(tmpdir(), "wmh-r2-"))), PUBLIC_BASE_URL: "http://localhost", - WMAILHUB_SIM_CF: "1", + // 真实 Cloudflare OAuth 客户端(测试里由 cf-mock 在传输层拦截,不外发) + CF_OAUTH_CLIENT_ID: "test-client-id", + CF_OAUTH_CLIENT_SECRET: "test-client-secret", WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: "1", }; const app = createApp(); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 2de8e38..091def2 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -1,31 +1,56 @@ -/** Cloudflare OAuth(本地 sim 模式)测试:Connect Account 自动接入、域名同步、state CSRF、setup Email Workers、Worker 凭据 */ -import { describe, expect, test } from "bun:test"; +/** + * Cloudflare OAuth(真实授权链路)测试: + * Connect Cloudflare Account 自动接入、域名同步、state CSRF、setup Email Workers、Worker 凭据。 + * Cloudflare 侧由 tests/cf-mock.ts 在 fetch 层打桩,断言的是真实发出的 HTTP 调用。 + */ +import { afterEach, describe, expect, test } from "bun:test"; import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts"; +import { installCloudflareMock, type CfMock } from "./cf-mock"; -describe("Cloudflare OAuth(sim 模式,Connect Cloudflare Account)", () => { - test("start 自动建账号;approve 完成自动接入并同步域名;setup 配置 Email Workers", async () => { +/** # 成员变量: 当前 Cloudflare 桩(afterEach 恢复原始 fetch) **/ +let mock: CfMock | null = null; +afterEach(() => { + mock?.restore(); + mock = null; +}); + +/** # 函数 + * 走完 start -> callback,返回账号 id 与桩(不携带会话 cookie 调 callback,模拟跨站重定向) + */ +async function connectAccount( + t: Awaited>, + cookie: string, +): Promise<{ accountId: string; cf: CfMock; resp: Response }> { + const cf = installCloudflareMock(); + mock = cf; + const start = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start") + ).json()) as { url: string; state: string }; + expect(start.url).toContain("https://dash.cloudflare.com/oauth2/auth"); + expect(start.url).toContain("client_id=test-client-id"); + + // 授权回调:浏览器从 Cloudflare 跨站跳回,不带 SameSite=Strict 会话 cookie + const resp = await t.app.fetch( + new Request(`http://localhost/api/oauth/cloudflare/callback?code=auth-code-1&state=${start.state}`), + t.env, + ); + const location = resp.headers.get("Location") ?? ""; + const accountId = new URLSearchParams(location.split("?")[1] ?? "").get("provision") ?? ""; + return { accountId, cf, resp }; +} + +describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => { + test("start 自动建账号;callback 完成自动接入并同步域名;setup 配置 Email Workers", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); - // Connect Cloudflare Account:start 自动创建账号记录 - const start = (await ( - await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start") - ).json()) as { url: string; state: string }; - expect(start.url).toContain("/oauth/sim/authorize?state="); - - // sim approve(等价于用户在 Cloudflare 授权页点击同意) - const approve = (await ( - await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state: start.state }), - }) - ).json()) as { ok: boolean; error?: string; account_id: string }; - expect(approve.ok).toBe(true); + const { accountId, cf, resp } = await connectAccount(t, cookie); + expect(resp.status).toBe(302); + expect(resp.headers.get("Location")).toContain("ok=1"); // 接入结果:发现 + 域名同步 + Email Worker 部署 + Worker 凭据签发 const detail = (await ( - await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.account_id}`) + await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}`) ).json()) as { account: { status: string; cloudflare_account_id: string | null; name: string; worker_id: string | null }; steps: Record; @@ -33,44 +58,54 @@ describe("Cloudflare OAuth(sim 模式,Connect Cloudflare Account)", () => { credential: { worker_id: string; secret_last4: string; status: string } | null; }; expect(detail.account.status).toBe("active"); // 接入完成(无心跳概念) - expect(detail.account.cloudflare_account_id).toBe("sim-account-0001"); - expect(detail.account.name).toBe("WmailHub Sim Account"); // 自动命名替换为 CF 账户名 + expect(detail.account.cloudflare_account_id).toBe("cf-acct-real-1"); + expect(detail.account.name).toBe("Real Cloudflare Account"); // 自动命名替换为 CF 账户名 for (const key of ["oauth", "discover", "domains", "worker"]) { expect(detail.steps[key].status).toBe("done"); } - expect(detail.account.worker_id).toContain("wmailhub-email-"); // Worker credential 已签发(库中不存明文 secret) + expect(detail.account.worker_id).toContain("wmailhub-email-"); // Worker credential 已签发 expect(detail.credential).not.toBeNull(); expect(detail.credential!.worker_id).toBe(detail.account.worker_id!); const credRow = await t.env.DB.prepare( "SELECT secret_hash, secret_enc, secret_last4 FROM worker_credentials WHERE cloudflare_account_id = ?", ) - .bind(approve.account_id) + .bind(accountId) .first<{ secret_hash: string; secret_enc: string; secret_last4: string }>(); expect(credRow?.secret_enc).toBeTruthy(); expect(credRow!.secret_last4.length).toBe(4); + // 真实发出的 Cloudflare 调用:换 token -> 账户发现 -> 域名发现 -> 上传 Email Worker 脚本 + expect(cf.hit("POST", "/oauth2/token")).toBe(true); + expect(cf.hit("GET", "/accounts")).toBe(true); + expect(cf.hit("GET", "/zones?account.id=")).toBe(true); + expect(cf.hit("PUT", "/workers/scripts/")).toBe(true); + // token 兑换请求带真实 code 与 client 凭据 + const tokenBody = cf.bodyOf("POST", "/oauth2/token") ?? ""; + expect(tokenBody).toContain("grant_type=authorization_code"); + expect(tokenBody).toContain("code=auth-code-1"); + expect(tokenBody).toContain("client_secret=test-client-secret"); + // OAuth 只同步域名,Email Routing 按域名手动触发 - expect(detail.domains.map((d) => d.name)).toEqual( - expect.arrayContaining(["example.test", "demo.test", "mailbox.dev"]), - ); + expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["example.com", "demo.com"])); const domList = (await ( await authedFetch(t, cookie, "http://localhost/api/domains") ).json()) as { items: { id: string; name: string; status: string; account_id: string | null }[] }; - // 种子环境自带一个 cfacct-1 下的 example.test(active);OAuth 同步出的新域名均为 pending - const synced = domList.items.filter((d) => ["demo.test", "mailbox.dev"].includes(d.name)); + const synced = domList.items.filter((d) => ["example.com", "demo.com"].includes(d.name)); expect(synced.length).toBe(2); for (const d of synced) expect(d.status).toBe("pending"); // catch-all 邮箱已随同步创建 const mb = await t.env.DB.prepare("SELECT address FROM mailboxes WHERE is_catch_all = 1").all<{ address: string }>(); - expect(mb.results.map((r) => r.address)).toContain("*@example.test"); + expect(mb.results.map((r) => r.address)).toContain("*@example.com"); - // setup:为同步出的 example.test 配置 Email Workers(Worker 幂等重部署 + Routing + catch-all 规则) - const example = domList.items.find((d) => d.name === "example.test" && d.status === "pending")!; + // setup:为同步出的 example.com 配置 Email Workers(Worker 幂等重部署 + Routing + catch-all 规则) + const example = domList.items.find((d) => d.name === "example.com" && d.status === "pending")!; const setup = (await ( await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" }) ).json()) as { ok: boolean; error?: string }; expect(setup.ok).toBe(true); + expect(cf.hit("POST", "/zones/zone-example/email/routing")).toBe(true); + expect(cf.hit("PUT", "/zones/zone-example/email/routing/rules/catch_all")).toBe(true); const after = (await ( await authedFetch(t, cookie, "http://localhost/api/domains") ).json()) as { items: { id: string; name: string; status: string; worker_id: string | null }[] }; @@ -78,72 +113,107 @@ describe("Cloudflare OAuth(sim 模式,Connect Cloudflare Account)", () => { expect(exampleAfter.status).toBe("active"); expect(exampleAfter.worker_id).toContain("wmailhub-email-"); - // sim 资源记录(Email Worker / Routing rule 只在 setup 后出现) - const sim = await t.env.DB.prepare("SELECT kind FROM cf_sim_resources").all<{ kind: string }>(); - const kinds = sim.results.map((r) => r.kind); - expect(kinds).toContain("zone"); - expect(kinds).toContain("email_worker"); - expect(kinds).toContain("routing_rule"); - // OAuth token 已加密入库(不含明文 token) const conn = await t.env.DB.prepare("SELECT encrypted_access_token FROM oauth_connections").first<{ encrypted_access_token: string; }>(); expect(conn?.encrypted_access_token).toBeTruthy(); - expect(conn!.encrypted_access_token.startsWith("sim_")).toBe(false); + expect(conn!.encrypted_access_token).not.toContain("cf-oauth-access-token-xyz"); }); - test("state 一次性:重放 approve 被拒(OAuth CSRF 防护)", async () => { + test("state 一次性:重放授权回调被拒(OAuth CSRF 防护)", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); + mock = installCloudflareMock(); const { state } = (await ( await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start") ).json()) as { state: string }; - const first = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state }), - }); - expect(first.status).toBe(200); + const first = await t.app.fetch( + new Request(`http://localhost/api/oauth/cloudflare/callback?code=c1&state=${state}`), + t.env, + ); + expect(first.status).toBe(302); // state 已消费,重放被拒 - const second = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state }), - }); + const second = await t.app.fetch( + new Request(`http://localhost/api/oauth/cloudflare/callback?code=c1&state=${state}`), + t.env, + ); expect(second.status).toBe(400); }); - test("伪造/过期 state 被拒;真实 callback 端点同样拒绝", async () => { + test("伪造 state 被拒;授权回调不依赖会话 cookie(跨站重定向场景)", async () => { const t = await makeTestEnv(); - const cookie = await loginCookie(t); - const bad = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state: "cfstate_forged" }), - }); - expect(bad.status).toBe(400); - const cb = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/callback?code=x&state=forged"); - expect(cb.status).toBe(400); - }); + mock = installCloudflareMock(); + // 无 Cookie:SameSite=Strict 下浏览器从 Cloudflare 跳回时就是这种请求 + const forged = await t.app.fetch( + new Request("http://localhost/api/oauth/cloudflare/callback?code=x&state=cfstate_forged"), + t.env, + ); + expect(forged.status).toBe(400); - test("rotate:Worker credential 轮换,secret 仅本次可见且已配置域名自动重部署", async () => { - const t = await makeTestEnv(); + // 合法 state + 无 Cookie 依然放行(CSRF 由一次性 state 承担) const cookie = await loginCookie(t); - // 自动接入一个账号(approve 走完 worker 步骤:部署 + 签发凭据) - await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start"); - const start = (await ( + const { state } = (await ( await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start") ).json()) as { state: string }; - const approve = (await ( - await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state: start.state }), - }) - ).json()) as { ok: boolean; account_id: string }; - const accountId = approve.account_id; + const okResp = await t.app.fetch( + new Request(`http://localhost/api/oauth/cloudflare/callback?code=c2&state=${state}`), + t.env, + ); + expect(okResp.status).toBe(302); + }); + + test("未配置 Cloudflare OAuth 时:mode=unconfigured,start/callback 返回 503(无降级通道)", async () => { + const t = await makeTestEnv(); + delete t.env.CF_OAUTH_CLIENT_ID; + delete t.env.CF_OAUTH_CLIENT_SECRET; + const cookie = await loginCookie(t); + + const mode = (await (await authedFetch(t, cookie, "http://localhost/api/oauth/mode")).json()) as { + mode: string; + authorize_url_base: string | null; + }; + expect(mode.mode).toBe("unconfigured"); + expect(mode.authorize_url_base).toBeNull(); + + const start = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start"); + expect(start.status).toBe(503); + // 不会凭空建账号 + const rows = await t.env.DB.prepare("SELECT id FROM cloudflare_accounts WHERE status = 'pending_oauth'").all(); + expect(rows.results.length).toBe(0); + + const cb = await t.app.fetch( + new Request("http://localhost/api/oauth/cloudflare/callback?code=x&state=y"), + t.env, + ); + expect(cb.status).toBe(503); + }); + + test("token 兑换失败时不落库连接,账号停留在待授权", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + mock = installCloudflareMock({ tokenStatus: 400 }); + const { state } = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start") + ).json()) as { state: string }; + + const resp = await t.app.fetch( + new Request(`http://localhost/api/oauth/cloudflare/callback?code=bad&state=${state}`), + t.env, + ); + expect(resp.status).toBe(500); + // 兑换失败发生在接入之前:不写 oauth_connections,账号仍是待授权 + const conns = await t.env.DB.prepare("SELECT id FROM oauth_connections").all(); + expect(conns.results.length).toBe(0); + const acct = await t.env.DB.prepare("SELECT status FROM cloudflare_accounts WHERE status = 'pending_oauth'").all(); + expect(acct.results.length).toBe(1); + }); + + test("rotate:Worker credential 轮换,secret 仅本次可见且旧 credential 撤销", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + const { accountId } = await connectAccount(t, cookie); const rotated = (await ( await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}/rotate`, { method: "POST" }) @@ -161,6 +231,21 @@ describe("Cloudflare OAuth(sim 模式,Connect Cloudflare Account)", () => { expect(creds.results.filter((r) => r.status === "revoked").length).toBe(1); }); + test("sim 通道已彻底移除:无 sim 授权端点、无 cf_sim_resources 表", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + const table = await t.db + .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cf_sim_resources'") + .get(); + expect(table).toBeNull(); + const resp = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: "cfstate_x" }), + }); + expect(resp.status).not.toBe(200); + }); + test("旧 Node 协议接口已移除(/api/internal/enroll 不再可用)", async () => { const t = await makeTestEnv(); // 该路径已无路由;管理面中间件按未授权拒绝,不会返回 enrollment 结果