From 1f4d45f71961031f24f429e5beadba200f5e36db Mon Sep 17 00:00:00 2001 From: bigcode Date: Wed, 30 Sep 2026 16:08:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v2.3.1=20Cloudflare=20OAuth=20scope=20?= =?UTF-8?q?=E7=82=B9=E5=8F=B7=E6=A0=BC=E5=BC=8F=20+=20=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=8F=AF=E8=AF=BB=E6=8F=90=E7=A4=BA=20+=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=87=AA=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:Cloudflare OAuth 的 scope 是点号分隔(account.read),冒号写法不被接受, 授权端点直接返回 error=invalid_scope。默认 scope 改为 account.read zone.read email_routing.write workers_scripts.write - scope 可在设置页自定义(settings.cf_oauth_scopes),需与 App 登记值逐字一致 - 新增 GET /api/oauth/cloudflare/preflight + 设置页「检测授权配置」按钮: 只读探测授权端点(不跟随跳转),区分 登录同意页 / 弹回回调 / Cloudflare /oauth/error, 并把 invalid_scope、invalid_request 翻译成可执行的处置建议 - 回调端点处理 error 参数:不再拿空 code 去兑换 token,改为跳回账号页并弹窗说明原因 (此前会落到 SPA 404 空壳页) - 账号页读取 ?oauth_error= 弹窗展示 Cloudflare 的原始描述 - docs/02 补 Cloudflare OAuth App 配置清单(Redirect URL / Client URI 域名校验 / Promote to public / 点号 scope);83 个测试全通过 --- README.md | 10 +- console/frontend/src/api.ts | 17 ++- .../frontend/src/components/SettingsModal.tsx | 63 +++++++- console/frontend/src/pages/Accounts.tsx | 19 +++ console/src/routes/oauth.ts | 36 ++++- console/src/routes/settings.ts | 11 +- console/src/services/cfapi.ts | 143 +++++++++++++++++- docs/02_CLOUDFLARE_OAUTH.md | 34 +++++ package.json | 2 +- tests/cf-mock.ts | 7 + tests/oauth.test.ts | 109 +++++++++++++ 11 files changed, 426 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 3aeab6a..5d19b98 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ export CF_OAUTH_REDIRECT_URI=http://127.0.0.1:18787/api/oauth/cloudflare/callbac - **用户登录**:用户名密码(PBKDF2),默认 admin/admin,HMAC 签名会话 cookie;密码在「设置」中修改。 - **账号管理**:「添加账号」直达 Cloudflare OAuth → 自动创建账号并同步该账号下全部域名、部署 Email Worker、签发 Worker 凭据;支持凭据轮换(轮换后已配置域名自动重新部署)。 -- **Cloudflare 接入配置**:设置 →「Cloudflare 接入」填写 OAuth Client ID / Secret(Secret AES-GCM 加密入库,只回显末 4 位),保存后立即生效、无需重新部署;未配置时「添加账号」返回 503。 +- **Cloudflare 接入配置**:设置 →「Cloudflare 接入」填写 OAuth Client ID / Secret(Secret AES-GCM 加密入库,只回显末 4 位)与申请的 scope,保存后立即生效、无需重新部署;「检测授权配置」按钮可在不开浏览器的情况下验证 OAuth App 是否配置正确(回调地址、Client URI 校验、是否 promote to public、scope 是否已授权)。未配置时「添加账号」返回 503。 - **批量操作**:账号 / 域名 / 邮箱 / 收件箱四个列表均支持复选框选择与批量删除。账号为软删(撤销 Worker 凭据);域名与邮箱级联删除其下邮件;邮件连同 R2 原文与附件一并清理。 - **域名**:由 OAuth 自动同步(无手工添加);点击域名「设置 Email Workers」= 确保账号级 Worker 部署 + 开启 Email Routing + catch-all 规则;支持按账号筛选。 - **两阶段接收**:authorize(SMTP 决策:accept/reject/temporary_error,拒绝码 DOMAIN_NOT_FOUND / DOMAIN_DISABLED / MAILBOX_NOT_FOUND / MAILBOX_DISABLED / RECEIVING_DISABLED)+ ingest(raw MIME 入库);动态邮箱创建/删除/禁用下一封邮件立即生效。 @@ -125,7 +125,13 @@ 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),回调填 `https://wmailhub.adx9.com/api/oauth/cloudflare/callback`(页面里会显示这个地址,直接复制)。Client ID / Secret 有两个配置位置,任一即可: +6. Cloudflare Dashboard 创建 OAuth App,需填齐四项(详见 docs/02): + - **Redirect URL**:`https://wmailhub.adx9.com/api/oauth/cloudflare/callback` + - **Client URI**:`https://wmailhub.adx9.com`(必须是 Console 站点域名),并按提示加 TXT 记录完成域名校验 + - **Promote to public**:私有 App 无法被授权 + - **Scopes**:`account.read zone.read email_routing.write workers_scripts.write`(**点号分隔**,冒号写法会被判 invalid_scope) + + 配好后到设置页点「检测授权配置」确认,再点账号页「添加账号」。Client ID / Secret 有两个配置位置,任一即可: - **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署; - 或部署时用 wrangler 配成 vars/secret(`CF_OAUTH_CLIENT_ID` / `CF_OAUTH_CLIENT_SECRET`),作为后台未配置时的兜底。 diff --git a/console/frontend/src/api.ts b/console/frontend/src/api.ts index b26401a..a0d2da7 100644 --- a/console/frontend/src/api.ts +++ b/console/frontend/src/api.ts @@ -306,6 +306,8 @@ export interface SettingsInfo { secret_last4: string; /** settings = 后台设置;env = 部署变量兜底;none = 未配置 */ source: "settings" | "env" | "none"; + /** 实际请求的 scope(点号分隔,空格分隔多个) */ + scope: string; }; /** 需要在 Cloudflare Dashboard 登记的授权回调地址 */ redirect_uri: string; @@ -317,9 +319,22 @@ export const getSettings = () => api("/api/settings"); * webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值 */ export const putSettings = (body: { webhook?: { url: string; headers: Record; key?: string }; - cf_oauth?: { client_id: string; secret?: string }; + cf_oauth?: { client_id: string; secret?: string; scope?: string }; }) => api("/api/settings", "PUT", body); +/** # 成员变量: 授权配置自检结果 **/ +export interface OAuthPreflightResult { + ok: boolean; + location: string; + error: string | null; + description: string | null; + hint: string | null; + used: { client_id: string; redirect_uri: string; scope: string }; +} + +/** 授权配置自检:探测 Cloudflare 授权端点是否接受当前配置(不产生授权) */ +export const oauthPreflight = () => api("/api/oauth/cloudflare/preflight"); + /** 清除后台保存的 Cloudflare OAuth 客户端凭据(env 兜底不受影响) */ export const deleteCfOAuth = () => api("/api/settings/cf-oauth", "DELETE"); diff --git a/console/frontend/src/components/SettingsModal.tsx b/console/frontend/src/components/SettingsModal.tsx index 7e7732e..4a9a181 100644 --- a/console/frontend/src/components/SettingsModal.tsx +++ b/console/frontend/src/components/SettingsModal.tsx @@ -34,7 +34,9 @@ import { deleteApiKey, deleteCfOAuth, getSettings, + oauthPreflight, putSettings, + type OAuthPreflightResult, type SettingsInfo, } from "../api.ts"; @@ -267,6 +269,7 @@ function ApiKeyCard({ interface CfOAuthFormValues { client_id: string; secret?: string; + scope: string; } /** # 函数 @@ -276,18 +279,35 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v const { message } = App.useApp(); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); + const [checking, setChecking] = useState(false); + const [check, setCheck] = useState(null); const meta = info.cf_oauth; useEffect(() => { - form.setFieldsValue({ client_id: meta.client_id }); - }, [meta.client_id, form]); + form.setFieldsValue({ client_id: meta.client_id, scope: meta.scope }); + }, [meta.client_id, meta.scope, form]); + + /** 授权配置自检:不打开浏览器,直接问 Cloudflare 这套配置能不能用 */ + const runPreflight = async () => { + setChecking(true); + setCheck(null); + try { + setCheck(await oauthPreflight()); + } catch (err) { + message.error(err instanceof Error ? err.message : "自检失败"); + } finally { + setChecking(false); + } + }; /** 保存 OAuth 客户端凭据 */ const save = async () => { const values = await form.validateFields(); setSaving(true); try { - await putSettings({ cf_oauth: { client_id: values.client_id.trim(), secret: values.secret } }); + await putSettings({ + cf_oauth: { client_id: values.client_id.trim(), secret: values.secret, scope: values.scope }, + }); message.success("Cloudflare OAuth 客户端已保存"); form.setFieldValue("secret", undefined); onSaved(); @@ -361,7 +381,23 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v > + + Cloudflare 的 OAuth scope 是点号分隔(account.read),冒号写法(account:read)会被判为 + invalid_scope。必须与 Cloudflare OAuth App 中登记的 scope 完全一致;App 未 promote to public + 或未授权这些 scope 时,点「检测授权配置」会直接报出来。 + + } + > + + + @@ -378,6 +414,27 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v )} + + {check && ( + + {check.description &&
{check.description}
} + {check.hint &&
{check.hint}
} +
+ 回调地址:{check.used.redirect_uri} · scope:{check.used.scope || "(空)"} +
+ + } + /> + )} ); } diff --git a/console/frontend/src/pages/Accounts.tsx b/console/frontend/src/pages/Accounts.tsx index 03b20d0..a80f077 100644 --- a/console/frontend/src/pages/Accounts.tsx +++ b/console/frontend/src/pages/Accounts.tsx @@ -68,6 +68,25 @@ export function AccountsPage() { useEffect(() => { void load(); + // 授权被 Cloudflare 拒绝时回调会带 ?oauth_error=...,把原因直接显示出来 + const oauthError = params.get("oauth_error"); + if (oauthError) { + const detailText = params.get("oauth_error_description") ?? ""; + modal.info({ + title: `Cloudflare 授权未完成(${oauthError})`, + content: ( +
+

{detailText || "Cloudflare 拒绝了本次授权请求。"}

+

+ 常见原因:OAuth App 未 promote to public、Client URI 域名未校验、 + 或 App 中登记的 scope 与本应用请求的不一致(需点号分隔)。 + 到「设置 → Cloudflare 接入」点「检测授权配置」可看到具体原因。 +

+
+ ), + }); + return; + } // OAuth 回调携带 ?provision= 时自动打开详情 const provisionId = params.get("provision"); if (provisionId) { diff --git a/console/src/routes/oauth.ts b/console/src/routes/oauth.ts index b74535e..4593f51 100644 --- a/console/src/routes/oauth.ts +++ b/console/src/routes/oauth.ts @@ -3,6 +3,7 @@ * GET /api/oauth/cloudflare/start 生成 state,返回授权跳转 URL(自动创建账号) * GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动接入 * GET /api/oauth/mode OAuth 是否已配置 + * GET /api/oauth/cloudflare/preflight 授权配置自检(只读探测 Cloudflare 授权端点) * GET /api/oauth/connections 已建立的 OAuth 连接 * * 流程(Connect Cloudflare Account):Console -> Cloudflare OAuth -> 用户授权 -> @@ -20,10 +21,12 @@ import { requireSession } from "../middleware.ts"; import { ids } from "../../../shared/src/index.ts"; import { CF_OAUTH_AUTHORIZE_URL, - CF_OAUTH_SCOPES, + buildAuthorizeUrl, getCFApi, getOAuthClient, + getOAuthScopes, oauthRedirectUri, + preflightOAuth, } from "../services/cfapi.ts"; import { provisionAccount, createAccount } from "../services/provision.ts"; import { encryptConnectionToken } from "../db.ts"; @@ -54,9 +57,16 @@ export function oauthRoutes(): App { mode: client ? "cloudflare" : "unconfigured", authorize_url_base: client ? CF_OAUTH_AUTHORIZE_URL : null, source: client?.source ?? "none", + scope: await getOAuthScopes(c.env), }); }); + /** 授权配置自检:不打开浏览器也能确认 OAuth App 是否可用(见 cfapi.preflightOAuth) */ + app.get("/cloudflare/preflight", async (c) => { + const result = await preflightOAuth(c.env, new URL(c.req.url).origin); + return c.json(result, result.ok ? 200 : 200); + }); + /** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */ app.get("/cloudflare/start", async (c) => { const client = await getOAuthClient(c.env); @@ -74,13 +84,12 @@ export function oauthRoutes(): App { .bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString()) .run(); - const redirectUri = oauthRedirectUri(c.env, new URL(c.req.url).origin); - const url = - `${CF_OAUTH_AUTHORIZE_URL}?response_type=code` + - `&client_id=${encodeURIComponent(client.clientId)}` + - `&redirect_uri=${encodeURIComponent(redirectUri)}` + - `&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` + - `&state=${encodeURIComponent(state)}`; + const url = buildAuthorizeUrl({ + clientId: client.clientId, + redirectUri: oauthRedirectUri(c.env, new URL(c.req.url).origin), + scopes: await getOAuthScopes(c.env), + state, + }); return c.json({ url, state }); }); @@ -96,6 +105,17 @@ export function oauthRoutes(): App { if (!verdict) { return c.text("OAuth state invalid or expired (possible CSRF)", 400); } + // 授权被 Cloudflare 拒绝(invalid_scope / access_denied ...):不兑换 token, + // 直接回控制台并带上错误信息,避免用户停在回调地址上看一个空壳页面。 + const oauthError = url.searchParams.get("error"); + if (oauthError) { + return c.redirect( + `/accounts?oauth_error=${encodeURIComponent(oauthError)}` + + `&oauth_error_description=${encodeURIComponent(url.searchParams.get("error_description") ?? "")}` + + `&account=${encodeURIComponent(verdict.accountId)}`, + 302, + ); + } const cf = await getCFApi(c.env); try { const redirectUri = oauthRedirectUri(c.env, url.origin); diff --git a/console/src/routes/settings.ts b/console/src/routes/settings.ts index 4f1b3b6..e90cb77 100644 --- a/console/src/routes/settings.ts +++ b/console/src/routes/settings.ts @@ -24,6 +24,7 @@ import { getSetting, keyHash, setSetting } from "../db.ts"; import { CF_OAUTH_SETTING_KEYS, clearOAuthClient, + getOAuthScopes, oauthClientMeta, oauthRedirectUri, saveOAuthClient, @@ -79,7 +80,7 @@ export function settingsRoutes(): App { username: user?.username ?? "", webhook, api_key: await apiKeyMeta(c.env), - cf_oauth: await oauthClientMeta(c.env), + cf_oauth: { ...(await oauthClientMeta(c.env)), scope: await getOAuthScopes(c.env) }, // 供前端展示:需要在 Cloudflare Dashboard 登记的回调地址 redirect_uri: oauthRedirectUri(c.env, new URL(c.req.url).origin), }); @@ -89,7 +90,7 @@ export function settingsRoutes(): App { app.put("/", async (c) => { const body = await readJson<{ webhook?: { url?: string; headers?: unknown; key?: string }; - cf_oauth?: { client_id?: string; secret?: string }; + cf_oauth?: { client_id?: string; secret?: string; scope?: string }; }>(c.req.raw); if (body.webhook) { @@ -115,6 +116,12 @@ export function settingsRoutes(): App { if (body.cf_oauth) { const clientId = (body.cf_oauth.client_id ?? "").trim(); + // scope 留空表示不改动;否则按空格分隔保存(去掉连续/首尾空白) + if (typeof body.cf_oauth.scope === "string") { + const scope = body.cf_oauth.scope.trim().replace(/\s+/g, " "); + if (scope) await setSetting(c.env, CF_OAUTH_SETTING_KEYS.scopes, scope); + else await c.env.DB.prepare("DELETE FROM settings WHERE key = ?").bind(CF_OAUTH_SETTING_KEYS.scopes).run(); + } // secret:undefined 保留现有;空白串(含 "")清除;非空设置新 secret const rawSecret = typeof body.cf_oauth.secret === "string" ? body.cf_oauth.secret : undefined; if (rawSecret === undefined) { diff --git a/console/src/services/cfapi.ts b/console/src/services/cfapi.ts index 58182ab..f49d945 100644 --- a/console/src/services/cfapi.ts +++ b/console/src/services/cfapi.ts @@ -27,11 +27,24 @@ export const CF_API_BASE = "https://api.cloudflare.com/client/v4"; /** # 成员变量: 授权回调路径 **/ export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback"; +/** # 成员变量: OAuth 申请权限(docs/08 最小权限:账户与 Zone 读、Email Routing 写、Workers 脚本写) + * 注意:Cloudflare OAuth 的 scope 是**点号分隔**的(冒号形式不被接受,见 Cloudflare + * "Create OAuth Client" API 文档:Colon-delimited scopes are not accepted)。 + * 冒号写法会让授权端点直接返回 error=invalid_scope。 + * 这里只是默认值,实际请求的 scope 来自 settings.cf_oauth_scopes(可在设置页改)。 **/ +export const CF_OAUTH_SCOPES_DEFAULT = [ + "account.read", + "zone.read", + "email_routing.write", + "workers_scripts.write", +].join(" "); + /** # 成员变量: settings 表中保存 OAuth 客户端凭据的 key **/ export const CF_OAUTH_SETTING_KEYS = { clientId: "cf_oauth_client_id", secretEnc: "cf_oauth_client_secret_enc", secretLast4: "cf_oauth_client_secret_last4", + scopes: "cf_oauth_scopes", } as const; /** # 成员变量: 已解析的 Cloudflare OAuth 客户端凭据 **/ @@ -107,6 +120,128 @@ export async function clearOAuthClient(env: Env): Promise { } } +/** # 函数 + * 解析实际请求的 scope 列表(settings 覆盖默认值;空白回退默认) + */ +export async function getOAuthScopes(env: Env): Promise { + const custom = (await getSetting(env, CF_OAUTH_SETTING_KEYS.scopes))?.trim(); + return custom || CF_OAUTH_SCOPES_DEFAULT; +} + +/** # 函数 + * 拼出 Cloudflare 授权页 URL + */ +export function buildAuthorizeUrl(opts: { clientId: string; redirectUri: string; scopes: string; state: string }): string { + return ( + `${CF_OAUTH_AUTHORIZE_URL}?response_type=code` + + `&client_id=${encodeURIComponent(opts.clientId)}` + + `&redirect_uri=${encodeURIComponent(opts.redirectUri)}` + + `&scope=${encodeURIComponent(opts.scopes)}` + + `&state=${encodeURIComponent(opts.state)}` + ); +} + +/** # 成员变量: 授权配置自检结果 **/ +export interface OAuthPreflight { + /** 授权端点是否接受当前 client_id / redirect_uri / scope 组合 */ + ok: boolean; + /** Cloudflare 返回的 Location 指向(登录同意页 = 配置正确) */ + location: string; + /** 错误码(invalid_scope / invalid_request / access_denied ...) */ + error: string | null; + /** 错误描述原文 */ + description: string | null; + /** 给用户的处置建议 */ + hint: string | null; + /** 本次自检使用的参数 */ + used: { client_id: string; redirect_uri: string; scope: string }; +} + +/** # 函数 + * 授权配置自检:用当前配置请求 Cloudflare 授权端点但**不跟随跳转**, + * 据跳转目标判断 OAuth App 是否配置正确(纯只读探测,不会产生授权)。 + * 这样不用开浏览器就能验证 Client URI 校验、是否已 promote to public、scope 是否已授权。 + */ +export async function preflightOAuth(env: Env, requestOrigin: string): Promise { + const client = await getOAuthClient(env); + const redirectUri = oauthRedirectUri(env, requestOrigin); + const scope = await getOAuthScopes(env); + const used = { client_id: client?.clientId ?? "", redirect_uri: redirectUri, scope }; + if (!client) { + return { + ok: false, + location: "", + error: "not_configured", + description: "尚未配置 Cloudflare OAuth 客户端", + hint: "先在下方填入 Client ID 与 Client Secret 并保存", + used, + }; + } + let resp: Response; + try { + resp = await fetch( + buildAuthorizeUrl({ clientId: client.clientId, redirectUri, scopes: scope, state: "wmh_preflight" }), + { redirect: "manual" }, + ); + } catch (err) { + return { + ok: false, + location: "", + error: "network", + description: err instanceof Error ? err.message : String(err), + hint: "Console 无法访问 dash.cloudflare.com,请检查出网", + used, + }; + } + const location = resp.headers.get("location") ?? ""; + if (resp.status >= 300 && resp.status < 400 && location) { + const target = new URL(location, redirectUri); + const callbackBase = redirectUri.split("?")[0]; + // Cloudflare 拒绝授权时有两种落点:弹回我们登记的回调地址,或跳它自己的 /oauth/error 页 + const isCallbackBounce = location.startsWith(callbackBase); + const isCfErrorPage = target.origin === "https://dash.cloudflare.com" && target.pathname.startsWith("/oauth/"); + if (isCallbackBounce || isCfErrorPage) { + const error = target.searchParams.get("error"); + const description = target.searchParams.get("error_description"); + return { ok: false, location, error, description, hint: hintForOAuthError(error), used }; + } + // 跳到登录/同意页 = client_id、redirect_uri、scope 全部被接受 + return { ok: true, location, error: null, description: null, hint: null, used }; + } + return { + ok: false, + location, + error: `http_${resp.status}`, + description: `授权端点返回 HTTP ${resp.status}`, + hint: "检查 Client ID 是否正确", + used, + }; +} + +/** # 函数 + * 把 Cloudflare 授权错误翻译成可执行的处置建议 + */ +function hintForOAuthError(error: string | null): string | null { + if (error === "invalid_scope") { + return ( + "OAuth App 未授权本应用请求的 scope(或是冒号写法)。到 Cloudflare Dashboard → " + + "My Profile → OAuth Applications 编辑该应用:Client URI 填 Console 域名(如 https://wmailhub.adx9.com)、" + + "按提示加 TXT 记录完成域名校验、Promote to public,并在 scopes 中加入本应用请求的点号格式 scope。" + + "也可在本页调整 scope 字段与 App 中登记的一致。" + ); + } + if (error === "invalid_request") { + return ( + "请求被拒:最常见是回调地址(Redirect URL)与应用里登记的不一致——请把本页显示的回调地址原样填进 " + + "Cloudflare OAuth App 的 Redirect URL(本应用不会用其它地址回调)。" + ); + } + if (error === "access_denied") { + return "授权被拒绝:请在 Cloudflare 页面确认同意授权。"; + } + return null; +} + /** # 函数 * 计算 OAuth 授权回调地址(优先 CF_OAUTH_REDIRECT_URI,其次 PUBLIC_BASE_URL,最后请求 origin) */ @@ -117,14 +252,6 @@ export function oauthRedirectUri(env: Env, requestOrigin: string): string { : `${base.replace(/\/$/, "")}${CF_OAUTH_CALLBACK_PATH}`; } -/** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/ -export const CF_OAUTH_SCOPES = [ - "account:read", - "zone:read", - "email_routing:edit", - "workers_scripts:edit", -].join(" "); - /** # 成员变量: 邮件发现的 Cloudflare 资源描述 **/ export interface CFAccount { id: string; diff --git a/docs/02_CLOUDFLARE_OAUTH.md b/docs/02_CLOUDFLARE_OAUTH.md index 1343cbe..9ac71e3 100644 --- a/docs/02_CLOUDFLARE_OAUTH.md +++ b/docs/02_CLOUDFLARE_OAUTH.md @@ -47,3 +47,37 @@ Console —— 系统不提供任何模拟/降级通道,不会出现"看起来接入成功"的假账号与假资源。 `GET /api/settings` 会返回 `cf_oauth.source`(settings / env / none)与 `redirect_uri`(需在 Cloudflare Dashboard 登记的回调地址),`GET /api/oauth/mode` 返回 `source` 供账号页判断。 + +## Cloudflare OAuth App 配置清单(docs 依据:Cloudflare "Create OAuth Client" API) + +在 新建应用后,**必须**完成下面几项,否则授权端点 +会直接拒绝(此时 Console 收不到 code,只有 `error=invalid_scope` / `invalid_request`): + +| 字段 | 填什么 | 不对时的报错 | +| --- | --- | --- | +| Redirect URL(s) | `https://wmailhub.adx9.com/api/oauth/cloudflare/callback`(本地联调另加 `http://127.0.0.1:18787/api/oauth/cloudflare/callback`) | `invalid_request`: redirect_uri does not match | +| **Client URI** | Console 站点首页,如 `https://wmailhub.adx9.com` | `promotion to public requires a verified client_uri host` | +| 域名校验 | 按 Cloudflare 提示在 client_uri 域下加 TXT 记录(形如 `cloudflare_oauth_client_publisher=...`) | 同上,一直卡在 not verified | +| Promote to public | 应用可见性从 private 提升为 public | 私有 App 不可被他人授权 | +| Scopes | 与 Console「设置 → Cloudflare 接入」里的 scope 字段**逐字一致** | `invalid_scope`: Client is not allowed to request scope '...' | + +### scope 必须是点号分隔 + +Cloudflare OAuth 的 scope 命名是 `account.read` 这种**点号**形式;冒号形式(`account:read`)不被接受, +授权端点会判为 `invalid_scope`。默认请求的四个: + +```text +account.read zone.read email_routing.write workers_scripts.write +``` + +Console 允许在设置里改这个字段,以便与 App 中登记的值保持一致(`settings.cf_oauth_scopes`)。 + +## 授权配置自检(不用开浏览器) + +`GET /api/oauth/cloudflare/preflight` 用当前配置请求一次 Cloudflare 授权端点但**不跟随跳转**,据跳转目标判定: + +- 跳到登录/同意页 → `ok: true`,配置可用; +- 弹回我们的回调地址,或跳 `dash.cloudflare.com/oauth/error` → `ok: false` 并带回 `error` / + `error_description` 与一条处置建议。 + +设置页「Cloudflare 接入」tab 的「检测授权配置」按钮就是调它。纯只读探测,不会产生任何授权。 diff --git a/package.json b/package.json index 29fd593..a16626b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wmailhub", - "version": "2.3.0", + "version": "2.3.1", "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)", "private": true, "type": "module", diff --git a/tests/cf-mock.ts b/tests/cf-mock.ts index 5da4411..259135c 100644 --- a/tests/cf-mock.ts +++ b/tests/cf-mock.ts @@ -29,6 +29,8 @@ export interface CfMockOptions { accessToken?: string; /** token 端点 HTTP 状态(测失败分支用) **/ tokenStatus?: number; + /** 授权端点(dash.cloudflare.com/oauth2/auth)的 Location 跳转目标;不传则跳登录页 **/ + authorizeRedirect?: string; } /** # 成员变量: Cloudflare API 桩句柄 **/ @@ -44,6 +46,7 @@ export interface CfMock { } const TOKEN_URL = "https://dash.cloudflare.com/oauth2/token"; +const CF_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth"; const API_BASE = "https://api.cloudflare.com/client/v4"; /** # 函数 @@ -89,6 +92,10 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock { headers: { "Content-Type": "application/json" }, }); + if (url.startsWith(CF_AUTHORIZE_URL)) { + const target = opts.authorizeRedirect ?? "https://dash.cloudflare.com/login"; + return new Response(null, { status: 302, headers: { Location: target } }); + } if (url === TOKEN_URL) { if ((opts.tokenStatus ?? 200) !== 200) { return new Response(JSON.stringify({ error: "invalid_grant" }), { status: opts.tokenStatus }); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 091def2..290d061 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -231,6 +231,115 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => { expect(creds.results.filter((r) => r.status === "revoked").length).toBe(1); }); + test("scope 用点号分隔(冒号会被 Cloudflare 判 invalid_scope),且可在设置里改", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + + // 默认 scope 是点号格式 + const start = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start") + ).json()) as { url: string }; + const scope = new URL(start.url).searchParams.get("scope") ?? ""; + expect(scope).toContain("account.read"); + expect(scope).not.toContain(":"); + + // settings 里自定义 scope 后生效 + await authedFetch(t, cookie, "http://localhost/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cf_oauth: { client_id: "test-client-id", scope: " account.read zone.read " } }), + }); + const start2 = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start") + ).json()) as { url: string }; + expect(new URL(start2.url).searchParams.get("scope")).toBe("account.read zone.read"); + }); + + test("授权失败(error=invalid_scope):回调不兑换 token,改跳控制台并带回原因", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + installCloudflareMock(); // 若发生 token 兑换会被记录,下面断言没有 + 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?error=invalid_scope&error_description=${encodeURIComponent( + "The OAuth 2.0 Client is not allowed to request scope 'account:read'.", + )}&state=${state}`, + ), + t.env, + ); + expect(resp.status).toBe(302); + const location = resp.headers.get("Location") ?? ""; + expect(location).toContain("/accounts?oauth_error=invalid_scope"); + expect(location).toContain("oauth_error_description="); + // 不落库任何连接 + const conns = await t.env.DB.prepare("SELECT id FROM oauth_connections").all(); + expect(conns.results.length).toBe(0); + }); + + test("授权配置自检:/cloudflare/preflight 汇总 Cloudflare 的判定并给出处置建议", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + + // 未配置 -> not_configured + delete t.env.CF_OAUTH_CLIENT_ID; + delete t.env.CF_OAUTH_CLIENT_SECRET; + type PreflightBody = { + ok: boolean; + error: string | null; + hint: string | null; + description?: string | null; + used: { redirect_uri: string }; + }; + let res = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight") + ).json()) as PreflightBody; + expect(res.ok).toBe(false); + expect(res.error).toBe("not_configured"); + expect(res.hint).toBeTruthy(); + expect(res.used.redirect_uri).toBe("http://localhost/api/oauth/cloudflare/callback"); + + // 通过设置写入凭据(env 已删),再自检 + await authedFetch(t, cookie, "http://localhost/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cf_oauth: { client_id: "preflight-client", secret: "preflight-secret" } }), + }); + + // Cloudflare 把请求弹回我们的回调地址 => invalid_scope,并给出可执行建议 + const redirectUri = "http://localhost/api/oauth/cloudflare/callback"; + installCloudflareMock({ + authorizeRedirect: `${redirectUri}?error=invalid_scope&error_description=The+requested+scope+is+invalid&state=wmh_preflight`, + }); + res = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight") + ).json()) as PreflightBody; + expect(res.ok).toBe(false); + expect(res.error).toBe("invalid_scope"); + expect(res.description).toContain("scope"); + expect(res.hint).toContain("Promote to public"); + + // 错误也可能落在 Cloudflare 自己的 /oauth/error 页(不弹回回调地址) + installCloudflareMock({ authorizeRedirect: "https://dash.cloudflare.com/oauth/error?error=invalid_request&error_description=redirect_uri+does+not+match&state=wmh_preflight" }); + res = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight") + ).json()) as PreflightBody; + expect(res.ok).toBe(false); + expect(res.error).toBe("invalid_request"); + expect(res.hint).toContain("Redirect URL"); + + // 跳去登录/同意页 => 配置可用 + installCloudflareMock({ authorizeRedirect: "https://dash.cloudflare.com/login" }); + res = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight") + ).json()) as PreflightBody; + expect(res.ok).toBe(true); + expect(res.error).toBeNull(); + }); + test("sim 通道已彻底移除:无 sim 授权端点、无 cf_sim_resources 表", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t);