diff --git a/README.md b/README.md index 5d19b98..2e75b0d 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ WmailHub/ - **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) + - **Scopes**(`zone.read` / `workers-scripts.write` / `email-routing-rule.write` / `account-settings.read`):**点号分隔**,冒号写法(`account:read`)是 wrangler 的第一方简写,第三方 client 会被拒;缺 `account-settings.read` 也能接入(自动从域名反推账号) 配好后到设置页点「检测授权配置」确认,再点账号页「添加账号」。Client ID / Secret 有两个配置位置,任一即可: - **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署; diff --git a/console/frontend/src/api.ts b/console/frontend/src/api.ts index a0d2da7..cc03c51 100644 --- a/console/frontend/src/api.ts +++ b/console/frontend/src/api.ts @@ -330,8 +330,17 @@ export interface OAuthPreflightResult { description: string | null; hint: string | null; used: { client_id: string; redirect_uri: string; scope: string }; + /** scope 探测:allowed=App 已授权、denied=还需在选择器里补勾 */ + scopes: { allowed: string[]; denied: string[]; probed: boolean; note: string | null }; } +/** 本应用默认需要的 scope(点号分隔)——与后端 CF_OAUTH_SCOPES_DEFAULT 保持一致 */ +export const CF_OAUTH_SCOPES_DEFAULT = "zone.read workers-scripts.write email-routing-rule.write"; + +/** 可选:勾了 Account Settings Read 才能显示真实账号名,否则用第一个域名代替 */ +export const CF_OAUTH_SCOPES_WITH_ACCOUNT_NAME = + "account-settings.read zone.read workers-scripts.write email-routing-rule.write"; + /** 授权配置自检:探测 Cloudflare 授权端点是否接受当前配置(不产生授权) */ export const oauthPreflight = () => api("/api/oauth/cloudflare/preflight"); diff --git a/console/frontend/src/components/SettingsModal.tsx b/console/frontend/src/components/SettingsModal.tsx index 4a9a181..bfac1e9 100644 --- a/console/frontend/src/components/SettingsModal.tsx +++ b/console/frontend/src/components/SettingsModal.tsx @@ -19,6 +19,7 @@ import { Popconfirm, Space, Tabs, + Tag, Typography, } from "antd"; import { @@ -36,6 +37,8 @@ import { getSettings, oauthPreflight, putSettings, + CF_OAUTH_SCOPES_DEFAULT, + CF_OAUTH_SCOPES_WITH_ACCOUNT_NAME, type OAuthPreflightResult, type SettingsInfo, } from "../api.ts"; @@ -386,9 +389,10 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v label="申请的 Scope(点号分隔,空格分隔多个)" extra={ <> - Cloudflare 的 OAuth scope 是点号分隔(account.read),冒号写法(account:read)会被判为 - invalid_scope。必须与 Cloudflare OAuth App 中登记的 scope 完全一致;App 未 promote to public - 或未授权这些 scope 时,点「检测授权配置」会直接报出来。 + Cloudflare 的 OAuth scope 是点号分隔(zone.read),冒号写法(zone:read)是 wrangler 的 + 第一方简写,第三方客户端会被判 invalid_scope。必须与 OAuth App 里勾选的权限一致;检测按钮会 + 直接告诉你哪些已授权、哪些还要补勾。account-settings.read 是可选的:勾了才显示真实账号名, + 不勾则用第一个域名代替,不影响接入。 } > @@ -415,6 +419,61 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v + {check && check.scopes.probed && ( + + +
+ 已授权(可直接使用):{" "} + {check.scopes.allowed.length ? ( + check.scopes.allowed.map((x) => {x}) + ) : ( + 无 + )} +
+
+ 待勾选(在 OAuth App 的选择器里补上):{" "} + {check.scopes.denied.length ? ( + check.scopes.denied.map((x) => {x}) + ) : ( + 无,App 已授权全部所需权限 + )} +
+ {check.scopes.note && {check.scopes.note}} + + + + {check.scopes.allowed.length > 0 && ( + + )} + +
+
+ )} + {check && ( v <> {check.description &&
{check.description}
} {check.hint &&
{check.hint}
} + {check.scopes.denied.length > 0 && ( +
+ 下面「Scope 探测结果」列出的待勾选项,对应 OAuth App 选择器里的同名权限。 +
+ )}
回调地址:{check.used.redirect_uri} · scope:{check.used.scope || "(空)"}
diff --git a/console/src/services/cfapi.ts b/console/src/services/cfapi.ts index f49d945..bcf056b 100644 --- a/console/src/services/cfapi.ts +++ b/console/src/services/cfapi.ts @@ -27,18 +27,37 @@ 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(可在设置页改)。 **/ +/** # 成员变量: 本应用实际需要的 scope(点号分隔;与 Cloudflare OAuth App 里勾选的权限对应) + * + * 重要背景(实测结论): + * - Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `.`; + * `account:read` 这种冒号写法是 **wrangler 的第一方简写**,第三方客户端请求会被直接拒。 + * - 「账号」不是 `account.read` 而是 `account-settings.read`(选择器里叫 Account Settings Read); + 它只用来取账号名,**默认不请求** —— 缺它时 discover() 会从 zone 的 account_id 反推账户。 + * - Email Routing 在选择器里是 **四个独立权限**,其中改 catch-all 规则对应 + * `email-routing-rule.write`(选择器里叫 Email Routing Rules Edit)。 + * - 客户端注册的 scope 是「上限」而不是「默认值」:请求里没写的 scope 不会自动生效。 + * 以上名称可用 /cloudflare/preflight 的 scope 探测功能现场核对(见 discoverAllowedScopes)。 + **/ export const CF_OAUTH_SCOPES_DEFAULT = [ - "account.read", "zone.read", - "email_routing.write", - "workers_scripts.write", + "workers-scripts.write", + "email-routing-rule.write", ].join(" "); +/** # 成员变量: scope 探测候选集(本应用所需 + 常见备选) + * 探测时逐个试:被 Cloudflare 点名拒绝的剔除,剩下的即为 App 已授权的集合。 **/ +export const CF_OAUTH_SCOPE_CANDIDATES = [ + "account-settings.read", + "zone.read", + "workers-scripts.write", + "email-routing-rule.write", + "email-routing-account-rule.read", + "email-routing-address.write", + "email-routing-suppression.write", + "zone-settings.read", +]; + /** # 成员变量: settings 表中保存 OAuth 客户端凭据的 key **/ export const CF_OAUTH_SETTING_KEYS = { clientId: "cf_oauth_client_id", @@ -155,6 +174,8 @@ export interface OAuthPreflight { hint: string | null; /** 本次自检使用的参数 */ used: { client_id: string; redirect_uri: string; scope: string }; + /** scope 探测结果:App 已授权 / 尚未授权 */ + scopes: { allowed: string[]; denied: string[]; probed: boolean; note: string | null }; } /** # 函数 @@ -167,6 +188,7 @@ export async function preflightOAuth(env: Env, requestOrigin: string): Promise { + const remaining = [...CF_OAUTH_SCOPE_CANDIDATES]; + const denied: string[] = []; + for (let i = 0; i < CF_OAUTH_SCOPE_CANDIDATES.length + 1; i++) { + if (remaining.length === 0) return { allowed: [], denied, error: null }; + let location = ""; + try { + const resp = await fetch( + buildAuthorizeUrl({ clientId, redirectUri, scopes: remaining.join(" "), state: "wmh_preflight" }), + { redirect: "manual" }, + ); + location = resp.headers.get("location") ?? ""; + } catch { + return { allowed: remaining, denied, error: "network" }; + } + if (!location) return { allowed: remaining, denied, error: "no_redirect" }; + const target = new URL(location, redirectUri); + const error = target.searchParams.get("error"); + if (error !== "invalid_scope") { + // 其它错误(回调地址不匹配等)与 scope 无关,停止并把剩余当作"未知" + return { allowed: remaining, denied, error }; + } + const named = /scope '([^']+)'/.exec(target.searchParams.get("error_description") ?? "")?.[1]; + const rejected = named && remaining.includes(named) ? named : remaining[0]; + denied.push(rejected); + remaining.splice(remaining.indexOf(rejected), 1); + } + return { allowed: remaining, denied, error: null }; +} + +/** # 函数 + * 对外包装的 scope 探测:返回可直接展示给用户的结果 + */ +export async function discoverAllowedScopes( + clientId: string, + redirectUri: string, +): Promise { + const res = await probeScopes(clientId, redirectUri); + if (res.error) { + return { + allowed: res.allowed, + denied: res.denied, + probed: false, + note: `scope 探测中断(${res.error});请在 Cloudflare App 里逐项核对权限`, + }; + } + return { allowed: res.allowed, denied: res.denied, probed: true, note: null }; +} + /** # 函数 * 把 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 中登记的一致。" + "OAuth App 不允许请求本应用需要的某些 scope。到 Cloudflare Dashboard → My Profile → OAuth Applications " + + "编辑该应用:Client URI 填 Console 域名(如 https://wmailhub.adx9.com)、按提示加 TXT 记录完成域名校验、" + + "Promote to public,并在选择器里勾选下方「待勾选」列出的权限。客户端注册的 scope 是上限而非默认," + + "没勾的权限即使账号有也不会生效。" ); } if (error === "invalid_request") { @@ -265,14 +353,22 @@ export interface CFZone { status: string; } +/** # 成员变量: 账号/域名发现结果 **/ +export interface CFDiscovery { + /** 目标 Cloudflare 账户(id + 展示名) */ + account: CFAccount; + /** 该账户(或 token 可见)下的域名 Zone */ + zones: CFZone[]; + /** 账号来源:accounts 接口,或因缺少账号级读权限而从 zone 的 account_id 反推 */ + accountSource: "accounts" | "zones"; +} + /** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/ export interface CFApi { /** 用 code 换 access token */ exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>; - /** 发现 token 可访问的账户 */ - listAccounts(accessToken: string): Promise; - /** 发现账户下的域名 Zone */ - listZones(accessToken: string, accountId: string): Promise; + /** 发现 token 可访问的账户 + 其下域名 */ + discover(accessToken: string): Promise; /** 创建或更新账号级 Email Worker(幂等) */ putEmailWorker(opts: { accessToken: string; @@ -345,20 +441,40 @@ export class RealCFApi implements CFApi { return this.api(token, "/accounts"); } - async listZones(token: string, accountId: string) { - // 分页拉全账号下的 Zone(每页 50,最多 20 页) + async listZones(token: string, accountId?: string) { + // 分页拉全 Zone(每页 50,最多 20 页);不给 accountId 时返回 token 可见的全部 Zone const out: CFZone[] = []; for (let page = 1; page <= 20; page++) { - const rows = await this.api( - token, - `/zones?account.id=${encodeURIComponent(accountId)}&per_page=50&page=${page}`, - ); + const query = new URLSearchParams({ per_page: "50", page: String(page) }); + if (accountId) query.set("account.id", accountId); + const rows = await this.api(token, `/zones?${query.toString()}`); out.push(...rows); if (rows.length < 50) break; } return out; } + /** 账号 + 域名发现。 + * 优先用 /accounts 拿账户名(需要账号级读权限); + * 没有该权限时退而求其次:直接列 token 可见的 Zone,用 zone 自带的 account_id 反推账户, + * 展示名用第一个域名代替 —— 这样只要授权了 zone.read 就能完成接入。 */ + async discover(token: string): Promise { + try { + const accounts = await this.listAccounts(token); + if (accounts.length > 0) { + const account = accounts[0]; + return { account, zones: await this.listZones(token, account.id), accountSource: "accounts" }; + } + } catch { + // 缺少账号级读权限,走下面的反推路径 + } + const zones = await this.listZones(token); + if (zones.length === 0) throw new Error("no cloudflare zone accessible with this token"); + const accountId = zones[0].account_id; + const more = zones.length > 1 ? ` 等 ${zones.length} 个域名` : ""; + return { account: { id: accountId, name: `${zones[0].name}${more}` }, zones, accountSource: "zones" }; + } + async putEmailWorker(opts: { accessToken: string; accountId: string; diff --git a/console/src/services/provision.ts b/console/src/services/provision.ts index 632a3db..b6abbb4 100644 --- a/console/src/services/provision.ts +++ b/console/src/services/provision.ts @@ -131,21 +131,18 @@ export async function provisionAccount(env: Env, input: ProvisionInput): Promise // 1. OAuth 授权 await mark("oauth", "done"); - // 2. Account/Zone 发现 + // 2. Account/Zone 发现(没有账号级读权限时自动从 zone 的 account_id 反推) await mark("discover", "running"); - const accounts = await cf.listAccounts(input.accessToken); - if (accounts.length === 0) throw new Error("no cloudflare account accessible"); - const cfAccount = input.cfAccountId - ? accounts.find((a) => a.id === input.cfAccountId) ?? accounts[0] - : accounts[0]; - // 自动创建的账号以 Cloudflare 账户名命名 + const found = await cf.discover(input.accessToken); + const cfAccount = found.account; + const zones = found.zones; + // 自动创建的账号以 Cloudflare 账户名(或反推出的展示名)命名 const newName = account.name.startsWith("Cloudflare 账号") ? cfAccount.name : account.name; await env.DB.prepare( "UPDATE cloudflare_accounts SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?", ) .bind(cfAccount.id, newName, nowIso(), input.accountId) .run(); - const zones = await cf.listZones(input.accessToken, cfAccount.id); await mark("discover", "done"); // 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱 diff --git a/docs/02_CLOUDFLARE_OAUTH.md b/docs/02_CLOUDFLARE_OAUTH.md index 9ac71e3..0abee65 100644 --- a/docs/02_CLOUDFLARE_OAUTH.md +++ b/docs/02_CLOUDFLARE_OAUTH.md @@ -61,16 +61,34 @@ Cloudflare Dashboard 登记的回调地址),`GET /api/oauth/mode` 返回 `source | Promote to public | 应用可见性从 private 提升为 public | 私有 App 不可被他人授权 | | Scopes | 与 Console「设置 → Cloudflare 接入」里的 scope 字段**逐字一致** | `invalid_scope`: Client is not allowed to request scope '...' | -### scope 必须是点号分隔 +### scope 词表与选择器的对应关系 -Cloudflare OAuth 的 scope 命名是 `account.read` 这种**点号**形式;冒号形式(`account:read`)不被接受, -授权端点会判为 `invalid_scope`。默认请求的四个: +Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `.`。`account:read` 这种冒号写法 +是 **wrangler 的第一方简写**,第三方客户端请求会被一律拒绝(它只对第一方 client 生效)。权威列表是 +`GET /client/v4/oauth/scopes`,选择器里是它的可勾选项。 -```text -account.read zone.read email_routing.write workers_scripts.write -``` +本应用默认请求这四个(已在 Cloudflare 侧逐个验证可被接受): -Console 允许在设置里改这个字段,以便与 App 中登记的值保持一致(`settings.cf_oauth_scopes`)。 +| scope id | 选择器里的名称 | 用途 | +| --- | --- | --- | +| `account-settings.read` | Account Settings Read | 列出账号(拿账号名) | +| `zone.read` | Zone Read | 发现该账号下的域名 | +| `workers-scripts.write` | Workers Scripts Edit | 上传账号级 Email Worker 脚本 | +| `email-routing-rule.write` | Email Routing Rules Edit | 开启 Email Routing + 配置 catch-all 规则 | + +两个容易踩的点: + +- **Email Routing 在选择器里是四个独立权限**(`Email Routing Account Rules` / `Addresses` / + `Rules` / `Suppressions`),不是笼统的"email routing 写"。改 catch-all 规则对应上面表里的 + `email-routing-rule.write`。 +- **客户端注册的 scope 是上限,不是默认值**。App 里没勾的权限,即使账号本身有,授权时也不会生效; + 反过来请求里不写某个 scope,即使 App 勾了也不会被授予。 + +### 缺少账号级读权限时的降级(2026-09-30 实测后加入) + +若 App 没有勾 `account-settings.read`,`GET /accounts` 会 403。Console 会自动降级:直接列 token 可见的 +`/zones`,用 zone 自带的 `account_id` 反推目标账户,账号展示名用第一个域名代替(如 `example.com 等 3 个域名`)。 +因此**只勾 `zone.read` + `workers-scripts.write` + `email-routing-rule.write` 也能完成接入**,只是账号名显示为域名。 ## 授权配置自检(不用开浏览器) @@ -81,3 +99,18 @@ Console 允许在设置里改这个字段,以便与 App 中登记的值保持一 `error_description` 与一条处置建议。 设置页「Cloudflare 接入」tab 的「检测授权配置」按钮就是调它。纯只读探测,不会产生任何授权。 + +### scope 探测:直接算出"哪些已授权、哪些还要勾" + +授权端点在**显示同意页之前**就校验 scope,拒绝时通过 `error_description` **点名**是哪一个 +(`... not allowed to request scope 'xxx'`)。于是可以逐轮「请求剩余候选 → 剔除被点名的那一个」, +收敛出该 App 实际持有的 scope 集合——每轮只是一个未认证请求,无需登录、无需同意、不产生任何授权。 + +`preflight` 在收到 `invalid_scope` 时会自动跑完这个探测,并在结果里返回: + +```json +"scopes": { "allowed": ["zone.read", "workers-scripts.write"], + "denied": ["account-settings.read"], "probed": true, "note": null } +``` + +设置页把两列分别渲染成绿色/橙色标签,「待勾选」列就是需要在 OAuth App 选择器里补上的权限。 diff --git a/package.json b/package.json index a16626b..e694c26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wmailhub", - "version": "2.3.1", + "version": "2.3.2", "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 259135c..6b9a0a8 100644 --- a/tests/cf-mock.ts +++ b/tests/cf-mock.ts @@ -29,8 +29,14 @@ export interface CfMockOptions { accessToken?: string; /** token 端点 HTTP 状态(测失败分支用) **/ tokenStatus?: number; - /** 授权端点(dash.cloudflare.com/oauth2/auth)的 Location 跳转目标;不传则跳登录页 **/ + /** 授权端点(dash.cloudflare.com/oauth2/auth)的固定 Location 跳转目标;不传则按 scopesAllowed 判定 **/ authorizeRedirect?: string; + /** 授权端点允许的 scope 集合;其余一律 invalid_scope 并点名第一个被拒的(不传 = 全拒) **/ + scopesAllowed?: string[]; + /** 授权端点固定错误(走 Cloudflare 自己的 /oauth/error 页,不弹回回调地址) **/ + authorizeError?: string; + /** /accounts 返回 403(模拟只授权了 zone 读、没有账号级读权限) */ + failAccounts?: boolean; } /** # 成员变量: Cloudflare API 桩句柄 **/ @@ -47,6 +53,7 @@ export interface CfMock { const TOKEN_URL = "https://dash.cloudflare.com/oauth2/token"; const CF_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth"; +const REDIRECT_URI = "http://localhost/api/oauth/cloudflare/callback"; const API_BASE = "https://api.cloudflare.com/client/v4"; /** # 函数 @@ -93,8 +100,29 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock { }); 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 (opts.authorizeError) { + return new Response(null, { + status: 302, + headers: { Location: `https://dash.cloudflare.com/oauth/error?error=${opts.authorizeError}&state=wmh_preflight` }, + }); + } + if (opts.authorizeRedirect) { + return new Response(null, { status: 302, headers: { Location: opts.authorizeRedirect } }); + } + // 默认行为:只有 scopesAllowed 里的 scope 才放行,否则点名第一个被拒的 + const allowed = opts.scopesAllowed ?? []; + const asked = (new URL(url).searchParams.get("scope") ?? "").split(" ").filter(Boolean); + const rejected = asked.find((sc) => !allowed.includes(sc)); + if (rejected) { + const desc = `The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope '${rejected}'.`; + return new Response(null, { + status: 302, + headers: { + Location: `${REDIRECT_URI}?error=invalid_scope&error_description=${encodeURIComponent(desc)}&state=wmh_preflight`, + }, + }); + } + return new Response(null, { status: 302, headers: { Location: "https://dash.cloudflare.com/login" } }); } if (url === TOKEN_URL) { if ((opts.tokenStatus ?? 200) !== 200) { @@ -106,7 +134,16 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock { headers: { "Content-Type": "application/json" }, }); } - if (url.startsWith(`${API_BASE}/accounts`)) return json(accounts); + if (url.startsWith(`${API_BASE}/accounts`)) { + // 只拦账户列表本身;workers/scripts 上传同为 /accounts 前缀,不应受影响 + if (opts.failAccounts && url === `${API_BASE}/accounts`) { + return new Response( + JSON.stringify({ success: false, errors: [{ code: 9109, message: "Invalid access token" }], result: null }), + { status: 403, headers: { "Content-Type": "application/json" } }, + ); + } + 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 }); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 290d061..6e8531a 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -77,7 +77,7 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => { // 真实发出的 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.calls.some((c) => c.path.startsWith("/zones?") && c.path.includes("account.id="))).toBe(true); expect(cf.hit("PUT", "/workers/scripts/")).toBe(true); // token 兑换请求带真实 code 与 client 凭据 const tokenBody = cf.bodyOf("POST", "/oauth2/token") ?? ""; @@ -121,6 +121,42 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => { expect(conn!.encrypted_access_token).not.toContain("cf-oauth-access-token-xyz"); }); + test("缺少账号级读权限时:从 zone 的 account_id 反推账户,接入照样完成", async () => { + const t = await makeTestEnv(); + const cookie = await loginCookie(t); + // /accounts 返回 403(没有 account 读取权限),zones 仍可用且带 account_id + const cf = installCloudflareMock({ + zones: [ + { id: "zone-a", name: "alpha.com", account_id: "cf-acct-from-zone", status: "active" }, + { id: "zone-b", name: "beta.com", account_id: "cf-acct-from-zone", status: "active" }, + ], + failAccounts: true, + }); + mock = cf; + 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=c1&state=${state}`), + t.env, + ); + expect(resp.status).toBe(302); + expect(resp.headers.get("Location")).toContain("ok=1"); + + const accountId = new URL("http://x" + resp.headers.get("Location")).searchParams.get("provision")!; + const detail = (await ( + await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}`) + ).json()) as { + account: { status: string; cloudflare_account_id: string | null; name: string }; + domains: { name: string }[]; + }; + expect(detail.account.status).toBe("active"); + expect(detail.account.cloudflare_account_id).toBe("cf-acct-from-zone"); + // 展示名用第一个域名 + 域名数量 + expect(detail.account.name).toBe("alpha.com 等 2 个域名"); + expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["alpha.com", "beta.com"])); + }); + test("state 一次性:重放授权回调被拒(OAuth CSRF 防护)", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); @@ -240,7 +276,8 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => { 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"); + // 默认只请求接入必需的三项(账号名权限可选,缺失时从 zone 反推) + expect(scope).toBe("zone.read workers-scripts.write email-routing-rule.write"); expect(scope).not.toContain(":"); // settings 里自定义 scope 后生效 @@ -293,6 +330,7 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => { hint: string | null; description?: string | null; used: { redirect_uri: string }; + scopes: { allowed: string[]; denied: string[]; probed: boolean; note: string | null }; }; let res = (await ( await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight") @@ -331,6 +369,52 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => { expect(res.error).toBe("invalid_request"); expect(res.hint).toContain("Redirect URL"); + // 默认 scope(三项)全被授权 => 直接通过 + installCloudflareMock({ scopesAllowed: ["zone.read", "workers-scripts.write", "email-routing-rule.write"] }); + res = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight") + ).json()) as PreflightBody; + expect(res.ok).toBe(true); + + // 主动请求可选的 account-settings.read 而 App 没勾 => 逐轮剔除被点名的,收敛出 allowed/denied + await authedFetch(t, cookie, "http://localhost/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + cf_oauth: { + client_id: "preflight-client", + scope: "account-settings.read zone.read workers-scripts.write email-routing-rule.write", + }, + }), + }); + 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.scopes.probed).toBe(true); + // 候选集里 App 没勾的(所需的 account-settings.read + 4 个备选)都会被点名剔除 + expect(res.scopes.denied).toEqual( + expect.arrayContaining([ + "account-settings.read", + "email-routing-account-rule.read", + "email-routing-address.write", + "email-routing-suppression.write", + "zone-settings.read", + ]), + ); + expect(res.scopes.allowed).toEqual( + expect.arrayContaining(["zone.read", "workers-scripts.write", "email-routing-rule.write"]), + ); + + // App 什么都没勾时:全部候选都算待勾选 + installCloudflareMock({ scopesAllowed: [] }); + res = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight") + ).json()) as PreflightBody; + expect(res.scopes.allowed).toEqual([]); + expect(res.scopes.denied.length).toBeGreaterThan(0); + // 跳去登录/同意页 => 配置可用 installCloudflareMock({ authorizeRedirect: "https://dash.cloudflare.com/login" }); res = (await (