refactor: v2.2.0 移除本地 sim 模式,Cloudflare OAuth 只走真实授权
- 删除 SimCFApi / WMAILHUB_SIM_CF / isRealOAuth / POST /api/oauth/cloudflare/sim/approve - 删除前端 OAuthSim 模拟授权页与 /oauth/sim/authorize 路由;账号页「添加账号」整页跳 Cloudflare - 迁移 0005 删除 cf_sim_resources 表 - 未配置 CF_OAUTH_CLIENT_ID/SECRET 时 start/callback 一律 503(不再降级出假账号假资源) - 修 OAuth 回调端点:跨站重定向不携带 SameSite=Strict 会话 cookie,改为由一次性 state 承担 CSRF 防护 - 测试改用 tests/cf-mock.ts 在 fetch 层拦截 Cloudflare API,断言真实 HTTP 调用(72/72 通过) - 本地 dev 无账号时只拉起 Console 并提示人工授权,不再自动造账号
This commit is contained in:
@@ -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"}
|
# 不存在的邮箱:{"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)
|
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
|
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 位随机串)
|
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`。
|
> 提示:`wrangler.jsonc` 里的 `database_id` 必须先填真实值,否则 `d1 execute` / `deploy` 都会报 `Invalid uuid`。
|
||||||
> 部署后自检:`curl https://wmailhub.adx9.com/api/health` → `{"ok":true,"name":"WmailHub","version":"<package.json 版本>"}`。
|
> 部署后自检:`curl https://wmailhub.adx9.com/api/health` → `{"ok":true,"name":"WmailHub","version":"<package.json 版本>"}`。
|
||||||
|
|||||||
@@ -153,14 +153,13 @@ export const getAccountWorkerSecret = (id: string) =>
|
|||||||
|
|
||||||
/* ---- OAuth ---- */
|
/* ---- 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 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 {
|
export interface DomainRow {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { ThemeContext } from "./theme-context.ts";
|
|||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
import { App } from "./App.tsx";
|
import { App } from "./App.tsx";
|
||||||
import { LoginPage } from "./pages/Login.tsx";
|
import { LoginPage } from "./pages/Login.tsx";
|
||||||
import { OAuthSimPage } from "./pages/OAuthSim.tsx";
|
|
||||||
import { DashboardPage } from "./pages/Dashboard.tsx";
|
import { DashboardPage } from "./pages/Dashboard.tsx";
|
||||||
import { AccountsPage } from "./pages/Accounts.tsx";
|
import { AccountsPage } from "./pages/Accounts.tsx";
|
||||||
import { DomainsPage } from "./pages/Domains.tsx";
|
import { DomainsPage } from "./pages/Domains.tsx";
|
||||||
@@ -20,7 +19,6 @@ import { setUnauthorizedHandler } from "./api.ts";
|
|||||||
/** # 成员变量: 路由表(邮件页路由 /inboxs,v2.1.0) **/
|
/** # 成员变量: 路由表(邮件页路由 /inboxs,v2.1.0) **/
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{ path: "/login", element: <LoginPage /> },
|
{ path: "/login", element: <LoginPage /> },
|
||||||
{ path: "/oauth/sim/authorize", element: <OAuthSimPage /> },
|
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <App />,
|
element: <App />,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** 账号页:添加账号直达 Cloudflare OAuth,授权后自动接入并同步域名;搜索、详情、Worker 凭据管理 */
|
/** 账号页:添加账号直达 Cloudflare OAuth,授权后自动接入并同步域名;搜索、详情、Worker 凭据管理 */
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
deleteAccount,
|
deleteAccount,
|
||||||
getAccount,
|
getAccount,
|
||||||
listAccounts,
|
listAccounts,
|
||||||
oauthMode,
|
|
||||||
oauthStart,
|
oauthStart,
|
||||||
rotateAccount,
|
rotateAccount,
|
||||||
updateAccount,
|
updateAccount,
|
||||||
@@ -47,7 +46,6 @@ const STEP_LABEL: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
export function AccountsPage() {
|
export function AccountsPage() {
|
||||||
const { message, modal } = App.useApp();
|
const { message, modal } = App.useApp();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const [items, setItems] = useState<AccountRow[]>([]);
|
const [items, setItems] = useState<AccountRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -90,13 +88,11 @@ export function AccountsPage() {
|
|||||||
[items, q],
|
[items, q],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页 */
|
/** 发起 OAuth:整页跳转到 Cloudflare 授权页(授权完成后浏览器重定向回 Console 回调端点) */
|
||||||
const startOAuth = async () => {
|
const startOAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const mode = (await oauthMode()).mode;
|
|
||||||
const { url } = await oauthStart();
|
const { url } = await oauthStart();
|
||||||
if (mode === "cloudflare") window.location.href = url;
|
window.location.href = url;
|
||||||
else navigate(url); // /oauth/sim/authorize?state=...
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error(err instanceof Error ? err.message : "发起授权失败");
|
message.error(err instanceof Error ? err.message : "发起授权失败");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="login-page">
|
|
||||||
<Card className="login-card" title="Cloudflare 授权(本地模拟)" styles={{ header: { textAlign: "center" } }}>
|
|
||||||
<p>sim 模式下,该页面模拟 Cloudflare OAuth 授权同意流程。</p>
|
|
||||||
<p className="muted">真实部署时,这里会跳转到 dash.cloudflare.com 的授权页面。</p>
|
|
||||||
<Space style={{ width: "100%", justifyContent: "center", marginTop: 16 }}>
|
|
||||||
<Button onClick={() => navigate("/accounts")}>拒绝</Button>
|
|
||||||
<Button type="primary" loading={busy} onClick={() => void approve()}>
|
|
||||||
同意授权
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -53,8 +53,6 @@ export interface Env {
|
|||||||
CF_OAUTH_CLIENT_ID?: string;
|
CF_OAUTH_CLIENT_ID?: string;
|
||||||
CF_OAUTH_CLIENT_SECRET?: string;
|
CF_OAUTH_CLIENT_SECRET?: string;
|
||||||
CF_OAUTH_REDIRECT_URI?: string;
|
CF_OAUTH_REDIRECT_URI?: string;
|
||||||
/** 强制使用 sim 模式("1") **/
|
|
||||||
WMAILHUB_SIM_CF?: string;
|
|
||||||
/** 本地联调:允许 webhook endpoint 指向私网/localhost("1"),生产严禁开启 **/
|
/** 本地联调:允许 webhook endpoint 指向私网/localhost("1"),生产严禁开启 **/
|
||||||
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string;
|
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,17 @@ const env: Env = {
|
|||||||
R2: new LocalR2(path.join(DATA_DIR, "r2")),
|
R2: new LocalR2(path.join(DATA_DIR, "r2")),
|
||||||
SESSION_SECRET: process.env.WMAILHUB_SESSION_SECRET,
|
SESSION_SECRET: process.env.WMAILHUB_SESSION_SECRET,
|
||||||
PUBLIC_BASE_URL: process.env.WMAILHUB_PUBLIC_BASE_URL ?? `http://127.0.0.1:${PORT}`,
|
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",
|
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<string, string> = {
|
const MIME: Record<string, string> = {
|
||||||
|
|||||||
+22
-30
@@ -2,7 +2,7 @@
|
|||||||
* Cloudflare OAuth 路由(docs/02、docs/11 §14):
|
* Cloudflare OAuth 路由(docs/02、docs/11 §14):
|
||||||
* GET /api/oauth/cloudflare/start 生成 state,返回授权跳转 URL(自动创建账号)
|
* GET /api/oauth/cloudflare/start 生成 state,返回授权跳转 URL(自动创建账号)
|
||||||
* GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动接入
|
* GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动接入
|
||||||
* POST /api/oauth/cloudflare/sim/approve sim 模式模拟用户授权(仅本地)
|
* GET /api/oauth/mode OAuth 是否已配置
|
||||||
* GET /api/oauth/connections 已建立的 OAuth 连接
|
* GET /api/oauth/connections 已建立的 OAuth 连接
|
||||||
*
|
*
|
||||||
* 流程(Connect Cloudflare Account):Console -> Cloudflare OAuth -> 用户授权 ->
|
* 流程(Connect Cloudflare Account):Console -> Cloudflare OAuth -> 用户授权 ->
|
||||||
@@ -10,46 +10,51 @@
|
|||||||
*
|
*
|
||||||
* 安全:state 一次性(用后即删)、5 分钟过期、绑定账号;token AES-GCM 加密入库,
|
* 安全:state 一次性(用后即删)、5 分钟过期、绑定账号;token AES-GCM 加密入库,
|
||||||
* 不进前端与日志;Account ownership 以 token 实际可访问的 accounts 为准。
|
* 不进前端与日志;Account ownership 以 token 实际可访问的 accounts 为准。
|
||||||
|
* 授权回调不校验会话:浏览器从 dash.cloudflare.com 跨站重定向回来时
|
||||||
|
* SameSite=Strict 的会话 cookie 不会携带,该端点由一次性 state 承担 CSRF 防护。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Env, AppVars } from "../env.ts";
|
import type { Env, AppVars } from "../env.ts";
|
||||||
import { requireSession } from "../middleware.ts";
|
import { requireSession } from "../middleware.ts";
|
||||||
import { ids } from "../../../shared/src/index.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 { provisionAccount, createAccount } from "../services/provision.ts";
|
||||||
import { encryptConnectionToken } from "../db.ts";
|
import { encryptConnectionToken } from "../db.ts";
|
||||||
import { nowIso, readJson } from "../util.ts";
|
import { nowIso } from "../util.ts";
|
||||||
|
|
||||||
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
||||||
|
|
||||||
/** # 成员变量: OAuth state 有效期(秒) **/
|
/** # 成员变量: OAuth state 有效期(秒) **/
|
||||||
const STATE_TTL_SECONDS = 300;
|
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 路由
|
* 装配 OAuth 路由
|
||||||
*/
|
*/
|
||||||
export function oauthRoutes(): App {
|
export function oauthRoutes(): App {
|
||||||
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
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 向导用) */
|
/** 授权模式与入口信息(前端 Connect Cloudflare Account 向导用) */
|
||||||
app.get("/mode", (c) => {
|
app.get("/mode", (c) => {
|
||||||
|
const configured = isOAuthConfigured(c.env);
|
||||||
return c.json({
|
return c.json({
|
||||||
mode: isRealOAuth(c.env) ? "cloudflare" : "sim",
|
mode: configured ? "cloudflare" : "unconfigured",
|
||||||
authorize_url_base: isRealOAuth(c.env) ? CF_OAUTH_AUTHORIZE_URL : null,
|
authorize_url_base: configured ? CF_OAUTH_AUTHORIZE_URL : null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */
|
/** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */
|
||||||
app.get("/cloudflare/start", async (c) => {
|
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, {
|
const created = await createAccount(c.env, {
|
||||||
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
|
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
|
||||||
});
|
});
|
||||||
@@ -61,7 +66,6 @@ export function oauthRoutes(): App {
|
|||||||
.bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
|
.bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
if (isRealOAuth(c.env)) {
|
|
||||||
const base = c.env.PUBLIC_BASE_URL ?? new URL(c.req.url).origin;
|
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 redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
|
||||||
const url =
|
const url =
|
||||||
@@ -71,13 +75,13 @@ export function oauthRoutes(): App {
|
|||||||
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
|
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
|
||||||
`&state=${encodeURIComponent(state)}`;
|
`&state=${encodeURIComponent(state)}`;
|
||||||
return c.json({ url, state });
|
return c.json({ url, state });
|
||||||
}
|
|
||||||
// sim 模式:前端内置模拟授权页
|
|
||||||
return c.json({ url: `/oauth/sim/authorize?state=${encodeURIComponent(state)}`, state });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */
|
/** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */
|
||||||
app.get("/cloudflare/callback", async (c) => {
|
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 url = new URL(c.req.url);
|
||||||
const code = url.searchParams.get("code") ?? "";
|
const code = url.searchParams.get("code") ?? "";
|
||||||
const state = url.searchParams.get("state") ?? "";
|
const state = url.searchParams.get("state") ?? "";
|
||||||
@@ -85,7 +89,7 @@ export function oauthRoutes(): App {
|
|||||||
if (!verdict) {
|
if (!verdict) {
|
||||||
return c.text("OAuth state invalid or expired (possible CSRF)", 400);
|
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 {
|
try {
|
||||||
const base = c.env.PUBLIC_BASE_URL ?? url.origin;
|
const base = c.env.PUBLIC_BASE_URL ?? url.origin;
|
||||||
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
|
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) */
|
/** 已有连接列表(不含 token) */
|
||||||
app.get("/connections", async (c) => {
|
app.get("/connections", async (c) => {
|
||||||
const rows = await c.env.DB.prepare(
|
const rows = await c.env.DB.prepare(
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Cloudflare API 客户端抽象。
|
* Cloudflare API 客户端:真实调用 api.cloudflare.com / dash.cloudflare.com。
|
||||||
*
|
*
|
||||||
* - RealCFApi:真实调用 api.cloudflare.com / dash.cloudflare.com(OAuth access token)
|
* OAuth access token 来自 Cloudflare OAuth 授权(docs/02),Console 用它完成
|
||||||
* - SimCFApi :本地 sim 模式。不访问外网,把"创建"的资源记录到 cf_sim_resources 表,
|
* 账号/域名发现、Email Worker 部署与 Email Routing 配置。
|
||||||
* 让 Connect Cloudflare Account 自动接入链路在本地完整可演示、可测试。
|
* 未配置 CF_OAUTH_CLIENT_ID/SECRET 时不提供任何降级通道——
|
||||||
*
|
* /api/oauth/cloudflare/start 直接返回 503,避免出现"看似接入成功"的假数据。
|
||||||
* Console 通过 env.CF_OAUTH_CLIENT_ID/SECRET 判断模式;两者齐备为 real,否则 sim。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { renderEmailWorkerScript } from "../../../shared/src/index.ts";
|
import { renderEmailWorkerScript } from "../../../shared/src/index.ts";
|
||||||
import type { Env } from "../env.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";
|
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";
|
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";
|
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
||||||
|
|
||||||
/** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/
|
/** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/
|
||||||
@@ -45,7 +42,7 @@ export interface CFZone {
|
|||||||
|
|
||||||
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
|
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
|
||||||
export interface CFApi {
|
export interface CFApi {
|
||||||
/** 用 code 换 access token(real);sim 直接签发假 token */
|
/** 用 code 换 access token */
|
||||||
exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>;
|
exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>;
|
||||||
/** 发现 token 可访问的账户 */
|
/** 发现 token 可访问的账户 */
|
||||||
listAccounts(accessToken: string): Promise<CFAccount[]>;
|
listAccounts(accessToken: string): Promise<CFAccount[]>;
|
||||||
@@ -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 {
|
export function isOAuthConfigured(env: Env): boolean {
|
||||||
const real = env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET && !env.WMAILHUB_SIM_CF;
|
return Boolean(env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET);
|
||||||
if (real) return new RealCFApi(env);
|
|
||||||
return new SimCFApi(db);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 成员变量: 真实 Cloudflare API 实现 **/
|
/** # 函数
|
||||||
|
* 构造 Cloudflare API 客户端
|
||||||
|
*/
|
||||||
|
export function getCFApi(env: Env): CFApi {
|
||||||
|
return new RealCFApi(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 成员变量: Cloudflare API 实现 **/
|
||||||
export class RealCFApi implements CFApi {
|
export class RealCFApi implements CFApi {
|
||||||
/** # 成员变量 **/
|
/** # 成员变量 **/
|
||||||
constructor(private env: Env) {}
|
constructor(private env: Env) {}
|
||||||
@@ -186,73 +188,3 @@ export class RealCFApi implements CFApi {
|
|||||||
return { ok: true };
|
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<void> {
|
|
||||||
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<CFAccount[]> {
|
|
||||||
// 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<CFZone[]> {
|
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export interface ProvisionInput {
|
|||||||
* 部署账号级 Email Worker + 签发 Worker credential。幂等,可重复调用;返回是否全部成功。
|
* 部署账号级 Email Worker + 签发 Worker credential。幂等,可重复调用;返回是否全部成功。
|
||||||
*/
|
*/
|
||||||
export async function provisionAccount(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
|
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 = ?")
|
const account = await env.DB.prepare("SELECT id, name, status FROM cloudflare_accounts WHERE id = ?")
|
||||||
.bind(input.accountId)
|
.bind(input.accountId)
|
||||||
.first<{ id: string; name: string; status: string }>();
|
.first<{ id: string; name: string; status: string }>();
|
||||||
@@ -249,7 +249,7 @@ async function putWorkerScript(
|
|||||||
workerId: string,
|
workerId: string,
|
||||||
workerSecret: string,
|
workerSecret: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cf: CFApi = getCFApi(env, env.DB);
|
const cf: CFApi = getCFApi(env);
|
||||||
const consoleUrl = env.PUBLIC_BASE_URL ?? "";
|
const consoleUrl = env.PUBLIC_BASE_URL ?? "";
|
||||||
if (!consoleUrl) throw new Error("console public url unknown (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 });
|
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);
|
const accessToken = await decryptConnectionToken(env);
|
||||||
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
|
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
|
||||||
|
|
||||||
const cf: CFApi = getCFApi(env, env.DB);
|
const cf: CFApi = getCFApi(env);
|
||||||
try {
|
try {
|
||||||
const workerId = await deployAccountWorker(env, account.id, accessToken);
|
const workerId = await deployAccountWorker(env, account.id, accessToken);
|
||||||
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
|
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// 2. `bunx wrangler r2 bucket create wmailhub` (已完成)
|
// 2. `bunx wrangler r2 bucket create wmailhub` (已完成)
|
||||||
// 3. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql` 起逐个执行 0001~0004
|
// 3. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql` 起逐个执行 0001~0004
|
||||||
// 4. `bunx wrangler secret put SESSION_SECRET` (>=32 位随机串)
|
// 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",
|
"$schema": "../node_modules/wrangler/config-schema.json",
|
||||||
"name": "wmailhub-console",
|
"name": "wmailhub-console",
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ Console
|
|||||||
|
|
||||||
- Cloudflare credential(OAuth access token)AES-GCM 加密存储,不进入前端和日志。
|
- Cloudflare credential(OAuth access token)AES-GCM 加密存储,不进入前端和日志。
|
||||||
- OAuth state 一次性(用后即删)、5 分钟过期、绑定账号(CSRF 防护)。
|
- OAuth state 一次性(用后即删)、5 分钟过期、绑定账号(CSRF 防护)。
|
||||||
|
- **授权回调不校验会话**:浏览器从 `dash.cloudflare.com` 跳回 Console 属于跨站顶级导航,`SameSite=Strict` 的会话 cookie 不会携带;该端点由一次性 state 承担 CSRF 防护,其余 `/api/oauth/*` 仍要求登录。
|
||||||
- Account ownership 以 token 实际可访问的 accounts 为准。
|
- Account ownership 以 token 实际可访问的 accounts 为准。
|
||||||
- 安装过程幂等,重复执行不产生重复资源。
|
- 安装过程幂等,重复执行不产生重复资源。
|
||||||
- Worker credential 与 Cloudflare credential 分离;轮换 credential 后已配置域名自动重新部署。
|
- Worker credential 与 Cloudflare credential 分离;轮换 credential 后已配置域名自动重新部署。
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
`CF_OAUTH_CLIENT_ID` 与 `CF_OAUTH_CLIENT_SECRET` 必须配置,否则 `/api/oauth/cloudflare/start` 与
|
||||||
|
`/api/oauth/cloudflare/callback` 一律返回 503 —— 系统不提供任何模拟/降级通道,
|
||||||
|
不会出现"看起来接入成功"的假账号与假资源。
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- v2.2.0:移除本地 sim 模式,Cloudflare OAuth 只走真实授权。
|
||||||
|
-- cf_sim_resources 记录的是「模拟 Cloudflare API 已创建的资源」,接入链路不再有模拟实现,整表废弃。
|
||||||
|
DROP TABLE IF EXISTS cf_sim_resources;
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wmailhub",
|
"name": "wmailhub",
|
||||||
"version": "2.1.2",
|
"version": "2.2.0",
|
||||||
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+17
-21
@@ -6,9 +6,9 @@
|
|||||||
* 流程:
|
* 流程:
|
||||||
* 1. 构建前端(vite build)
|
* 1. 构建前端(vite build)
|
||||||
* 2. 启动 Console(:18787,自动迁移 + admin/admin 种子)
|
* 2. 启动 Console(:18787,自动迁移 + admin/admin 种子)
|
||||||
* 3. 确保存在账号(无则 OAuth sim 自动创建账号),并逐域名配置 Email Workers
|
* 3. 已有 OAuth 接入的账号则为其配置未激活域名的 Email Workers
|
||||||
* (域名 setup 会部署账号级 Email Worker + 签发 WorkerCredential)
|
* (域名 setup 会部署账号级 Email Worker + 签发 WorkerCredential),然后启动本地 Email Worker
|
||||||
* 4. 启动 Email Worker(:18789,authorize/ingest 直连 Console)
|
* 4. 没有账号时只跑 Console:Cloudflare OAuth 需要浏览器人工授权,不再有模拟通道
|
||||||
* 5. Ctrl-C 一起退出
|
* 5. Ctrl-C 一起退出
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -78,28 +78,23 @@ if (!loginResp.ok) throw new Error("admin 登录失败");
|
|||||||
const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0];
|
const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0];
|
||||||
const authHeaders = { Cookie: cookie, "Content-Type": "application/json" };
|
const authHeaders = { Cookie: cookie, "Content-Type": "application/json" };
|
||||||
|
|
||||||
// 3. 确保账号存在(无则走 OAuth 自动创建)
|
// 3. 账号:OAuth 授权必须由浏览器人工完成,这里只消费已接入的账号
|
||||||
const accountsResp = await fetch(`${BASE}/api/accounts`, { headers: authHeaders });
|
const accountsResp = await fetch(`${BASE}/api/accounts`, { headers: authHeaders });
|
||||||
const existing = ((await accountsResp.json()) as { items: { id: string; name: string }[] }).items;
|
const existing = ((await accountsResp.json()) as { items: { id: string; name: string }[] }).items;
|
||||||
let accountId: string;
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length === 0) {
|
||||||
accountId = existing[0].id;
|
console.log("");
|
||||||
console.log(`[dev] 复用既有账号 ${accountId}`);
|
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 {
|
} else {
|
||||||
// 添加账号:OAuth start 自动创建账号,授权后自动接入
|
const accountId = existing[0].id;
|
||||||
const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start`, { headers: authHeaders });
|
console.log(`[dev] 使用已接入账号 ${accountId}`);
|
||||||
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 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} 并同步域名`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 域名同步后逐域名配置 Email Workers(部署 Worker + Routing + catch-all;幂等)
|
// 4. 域名同步后逐域名配置 Email Workers(部署 Worker + Routing + catch-all;幂等)
|
||||||
const domainsResp = await fetch(`${BASE}/api/domains`, { headers: authHeaders });
|
const domainsResp = await fetch(`${BASE}/api/domains`, { headers: authHeaders });
|
||||||
@@ -133,6 +128,7 @@ console.log(" 发测试邮件 : curl -X POST http://127.0.0.1:18789/__email_te
|
|||||||
console.log(" -H 'X-WmailHub-Test-Secret: <worker secret>' \\");
|
console.log(" -H 'X-WmailHub-Test-Secret: <worker secret>' \\");
|
||||||
console.log(" -H 'X-WmailHub-Test-To: hello@example.test' --data-binary @mail.eml");
|
console.log(" -H 'X-WmailHub-Test-To: hello@example.test' --data-binary @mail.eml");
|
||||||
console.log("==========================================");
|
console.log("==========================================");
|
||||||
|
}
|
||||||
|
|
||||||
/** 退出时统一收割子进程 */
|
/** 退出时统一收割子进程 */
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
|
|||||||
@@ -33,5 +33,4 @@ export const ids = {
|
|||||||
apiKey: () => newId("key"),
|
apiKey: () => newId("key"),
|
||||||
oauthState: () => newId("st"),
|
oauthState: () => newId("st"),
|
||||||
credential: () => newId("cred"),
|
credential: () => newId("cred"),
|
||||||
simResource: () => newId("sim"),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<Response> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-1
@@ -75,7 +75,9 @@ export async function makeTestEnv(): Promise<TestEnv> {
|
|||||||
DB: new LocalD1(db),
|
DB: new LocalD1(db),
|
||||||
R2: new LocalR2(mkdtempSync(path.join(tmpdir(), "wmh-r2-"))),
|
R2: new LocalR2(mkdtempSync(path.join(tmpdir(), "wmh-r2-"))),
|
||||||
PUBLIC_BASE_URL: "http://localhost",
|
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",
|
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: "1",
|
||||||
};
|
};
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|||||||
+159
-74
@@ -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 { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
|
||||||
|
import { installCloudflareMock, type CfMock } from "./cf-mock";
|
||||||
|
|
||||||
describe("Cloudflare OAuth(sim 模式,Connect Cloudflare Account)", () => {
|
/** # 成员变量: 当前 Cloudflare 桩(afterEach 恢复原始 fetch) **/
|
||||||
test("start 自动建账号;approve 完成自动接入并同步域名;setup 配置 Email Workers", async () => {
|
let mock: CfMock | null = null;
|
||||||
const t = await makeTestEnv();
|
afterEach(() => {
|
||||||
const cookie = await loginCookie(t);
|
mock?.restore();
|
||||||
|
mock = null;
|
||||||
|
});
|
||||||
|
|
||||||
// Connect Cloudflare Account:start 自动创建账号记录
|
/** # 函数
|
||||||
|
* 走完 start -> callback,返回账号 id 与桩(不携带会话 cookie 调 callback,模拟跨站重定向)
|
||||||
|
*/
|
||||||
|
async function connectAccount(
|
||||||
|
t: Awaited<ReturnType<typeof makeTestEnv>>,
|
||||||
|
cookie: string,
|
||||||
|
): Promise<{ accountId: string; cf: CfMock; resp: Response }> {
|
||||||
|
const cf = installCloudflareMock();
|
||||||
|
mock = cf;
|
||||||
const start = (await (
|
const start = (await (
|
||||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||||
).json()) as { url: string; state: string };
|
).json()) as { url: string; state: string };
|
||||||
expect(start.url).toContain("/oauth/sim/authorize?state=");
|
expect(start.url).toContain("https://dash.cloudflare.com/oauth2/auth");
|
||||||
|
expect(start.url).toContain("client_id=test-client-id");
|
||||||
|
|
||||||
// sim approve(等价于用户在 Cloudflare 授权页点击同意)
|
// 授权回调:浏览器从 Cloudflare 跨站跳回,不带 SameSite=Strict 会话 cookie
|
||||||
const approve = (await (
|
const resp = await t.app.fetch(
|
||||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=auth-code-1&state=${start.state}`),
|
||||||
method: "POST",
|
t.env,
|
||||||
headers: { "Content-Type": "application/json" },
|
);
|
||||||
body: JSON.stringify({ state: start.state }),
|
const location = resp.headers.get("Location") ?? "";
|
||||||
})
|
const accountId = new URLSearchParams(location.split("?")[1] ?? "").get("provision") ?? "";
|
||||||
).json()) as { ok: boolean; error?: string; account_id: string };
|
return { accountId, cf, resp };
|
||||||
expect(approve.ok).toBe(true);
|
}
|
||||||
|
|
||||||
|
describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
||||||
|
test("start 自动建账号;callback 完成自动接入并同步域名;setup 配置 Email Workers", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
|
||||||
|
const { accountId, cf, resp } = await connectAccount(t, cookie);
|
||||||
|
expect(resp.status).toBe(302);
|
||||||
|
expect(resp.headers.get("Location")).toContain("ok=1");
|
||||||
|
|
||||||
// 接入结果:发现 + 域名同步 + Email Worker 部署 + Worker 凭据签发
|
// 接入结果:发现 + 域名同步 + Email Worker 部署 + Worker 凭据签发
|
||||||
const detail = (await (
|
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 {
|
).json()) as {
|
||||||
account: { status: string; cloudflare_account_id: string | null; name: string; worker_id: string | null };
|
account: { status: string; cloudflare_account_id: string | null; name: string; worker_id: string | null };
|
||||||
steps: Record<string, { status: string }>;
|
steps: Record<string, { status: string }>;
|
||||||
@@ -33,44 +58,54 @@ describe("Cloudflare OAuth(sim 模式,Connect Cloudflare Account)", () => {
|
|||||||
credential: { worker_id: string; secret_last4: string; status: string } | null;
|
credential: { worker_id: string; secret_last4: string; status: string } | null;
|
||||||
};
|
};
|
||||||
expect(detail.account.status).toBe("active"); // 接入完成(无心跳概念)
|
expect(detail.account.status).toBe("active"); // 接入完成(无心跳概念)
|
||||||
expect(detail.account.cloudflare_account_id).toBe("sim-account-0001");
|
expect(detail.account.cloudflare_account_id).toBe("cf-acct-real-1");
|
||||||
expect(detail.account.name).toBe("WmailHub Sim Account"); // 自动命名替换为 CF 账户名
|
expect(detail.account.name).toBe("Real Cloudflare Account"); // 自动命名替换为 CF 账户名
|
||||||
for (const key of ["oauth", "discover", "domains", "worker"]) {
|
for (const key of ["oauth", "discover", "domains", "worker"]) {
|
||||||
expect(detail.steps[key].status).toBe("done");
|
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).not.toBeNull();
|
||||||
expect(detail.credential!.worker_id).toBe(detail.account.worker_id!);
|
expect(detail.credential!.worker_id).toBe(detail.account.worker_id!);
|
||||||
const credRow = await t.env.DB.prepare(
|
const credRow = await t.env.DB.prepare(
|
||||||
"SELECT secret_hash, secret_enc, secret_last4 FROM worker_credentials WHERE cloudflare_account_id = ?",
|
"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 }>();
|
.first<{ secret_hash: string; secret_enc: string; secret_last4: string }>();
|
||||||
expect(credRow?.secret_enc).toBeTruthy();
|
expect(credRow?.secret_enc).toBeTruthy();
|
||||||
expect(credRow!.secret_last4.length).toBe(4);
|
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 按域名手动触发
|
// OAuth 只同步域名,Email Routing 按域名手动触发
|
||||||
expect(detail.domains.map((d) => d.name)).toEqual(
|
expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["example.com", "demo.com"]));
|
||||||
expect.arrayContaining(["example.test", "demo.test", "mailbox.dev"]),
|
|
||||||
);
|
|
||||||
const domList = (await (
|
const domList = (await (
|
||||||
await authedFetch(t, cookie, "http://localhost/api/domains")
|
await authedFetch(t, cookie, "http://localhost/api/domains")
|
||||||
).json()) as { items: { id: string; name: string; status: string; account_id: string | null }[] };
|
).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) => ["example.com", "demo.com"].includes(d.name));
|
||||||
const synced = domList.items.filter((d) => ["demo.test", "mailbox.dev"].includes(d.name));
|
|
||||||
expect(synced.length).toBe(2);
|
expect(synced.length).toBe(2);
|
||||||
for (const d of synced) expect(d.status).toBe("pending");
|
for (const d of synced) expect(d.status).toBe("pending");
|
||||||
|
|
||||||
// catch-all 邮箱已随同步创建
|
// catch-all 邮箱已随同步创建
|
||||||
const mb = await t.env.DB.prepare("SELECT address FROM mailboxes WHERE is_catch_all = 1").all<{ address: string }>();
|
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 规则)
|
// setup:为同步出的 example.com 配置 Email Workers(Worker 幂等重部署 + Routing + catch-all 规则)
|
||||||
const example = domList.items.find((d) => d.name === "example.test" && d.status === "pending")!;
|
const example = domList.items.find((d) => d.name === "example.com" && d.status === "pending")!;
|
||||||
const setup = (await (
|
const setup = (await (
|
||||||
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
|
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
|
||||||
).json()) as { ok: boolean; error?: string };
|
).json()) as { ok: boolean; error?: string };
|
||||||
expect(setup.ok).toBe(true);
|
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 (
|
const after = (await (
|
||||||
await authedFetch(t, cookie, "http://localhost/api/domains")
|
await authedFetch(t, cookie, "http://localhost/api/domains")
|
||||||
).json()) as { items: { id: string; name: string; status: string; worker_id: string | null }[] };
|
).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.status).toBe("active");
|
||||||
expect(exampleAfter.worker_id).toContain("wmailhub-email-");
|
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)
|
// OAuth token 已加密入库(不含明文 token)
|
||||||
const conn = await t.env.DB.prepare("SELECT encrypted_access_token FROM oauth_connections").first<{
|
const conn = await t.env.DB.prepare("SELECT encrypted_access_token FROM oauth_connections").first<{
|
||||||
encrypted_access_token: string;
|
encrypted_access_token: string;
|
||||||
}>();
|
}>();
|
||||||
expect(conn?.encrypted_access_token).toBeTruthy();
|
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 t = await makeTestEnv();
|
||||||
const cookie = await loginCookie(t);
|
const cookie = await loginCookie(t);
|
||||||
|
mock = installCloudflareMock();
|
||||||
const { state } = (await (
|
const { state } = (await (
|
||||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||||
).json()) as { state: string };
|
).json()) as { state: string };
|
||||||
|
|
||||||
const first = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
|
const first = await t.app.fetch(
|
||||||
method: "POST",
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=c1&state=${state}`),
|
||||||
headers: { "Content-Type": "application/json" },
|
t.env,
|
||||||
body: JSON.stringify({ state }),
|
);
|
||||||
});
|
expect(first.status).toBe(302);
|
||||||
expect(first.status).toBe(200);
|
|
||||||
// state 已消费,重放被拒
|
// state 已消费,重放被拒
|
||||||
const second = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
|
const second = await t.app.fetch(
|
||||||
method: "POST",
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=c1&state=${state}`),
|
||||||
headers: { "Content-Type": "application/json" },
|
t.env,
|
||||||
body: JSON.stringify({ state }),
|
);
|
||||||
});
|
|
||||||
expect(second.status).toBe(400);
|
expect(second.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("伪造/过期 state 被拒;真实 callback 端点同样拒绝", async () => {
|
test("伪造 state 被拒;授权回调不依赖会话 cookie(跨站重定向场景)", async () => {
|
||||||
const t = await makeTestEnv();
|
const t = await makeTestEnv();
|
||||||
const cookie = await loginCookie(t);
|
mock = installCloudflareMock();
|
||||||
const bad = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
|
// 无 Cookie:SameSite=Strict 下浏览器从 Cloudflare 跳回时就是这种请求
|
||||||
method: "POST",
|
const forged = await t.app.fetch(
|
||||||
headers: { "Content-Type": "application/json" },
|
new Request("http://localhost/api/oauth/cloudflare/callback?code=x&state=cfstate_forged"),
|
||||||
body: JSON.stringify({ state: "cfstate_forged" }),
|
t.env,
|
||||||
});
|
);
|
||||||
expect(bad.status).toBe(400);
|
expect(forged.status).toBe(400);
|
||||||
const cb = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/callback?code=x&state=forged");
|
|
||||||
expect(cb.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rotate:Worker credential 轮换,secret 仅本次可见且已配置域名自动重部署", async () => {
|
// 合法 state + 无 Cookie 依然放行(CSRF 由一次性 state 承担)
|
||||||
const t = await makeTestEnv();
|
|
||||||
const cookie = await loginCookie(t);
|
const cookie = await loginCookie(t);
|
||||||
// 自动接入一个账号(approve 走完 worker 步骤:部署 + 签发凭据)
|
const { state } = (await (
|
||||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start");
|
|
||||||
const start = (await (
|
|
||||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||||
).json()) as { state: string };
|
).json()) as { state: string };
|
||||||
const approve = (await (
|
const okResp = await t.app.fetch(
|
||||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=c2&state=${state}`),
|
||||||
method: "POST",
|
t.env,
|
||||||
headers: { "Content-Type": "application/json" },
|
);
|
||||||
body: JSON.stringify({ state: start.state }),
|
expect(okResp.status).toBe(302);
|
||||||
})
|
});
|
||||||
).json()) as { ok: boolean; account_id: string };
|
|
||||||
const accountId = approve.account_id;
|
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 (
|
const rotated = (await (
|
||||||
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}/rotate`, { method: "POST" })
|
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);
|
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 () => {
|
test("旧 Node 协议接口已移除(/api/internal/enroll 不再可用)", async () => {
|
||||||
const t = await makeTestEnv();
|
const t = await makeTestEnv();
|
||||||
// 该路径已无路由;管理面中间件按未授权拒绝,不会返回 enrollment 结果
|
// 该路径已无路由;管理面中间件按未授权拒绝,不会返回 enrollment 结果
|
||||||
|
|||||||
Reference in New Issue
Block a user