fix: v2.3.2 用探测确定真实 scope 词表,接入已实测可用

- 上一版默认 scope 名是猜的(account.read / email_routing.write),全部被拒。
  正确词表是点号分隔的 <group>.<verb>,且:
  - 账号是 account-settings.read(选择器里叫 Account Settings Read),不是 account.read
  - Workers 脚本是 workers-scripts.write,不是 workers_scripts.write
  - Email Routing 在选择器里是四个独立权限,改 catch-all 对应 email-routing-rule.write
  - account:read 这种冒号写法是 wrangler 的第一方简写,第三方 client 一律被拒
  - client 注册的 scope 是上限不是默认:没勾的权限即使账号有也不生效
- preflight 扩展 scope 探测:授权端点在同意页之前就校验并点名拒绝的 scope,
  逐轮「请求剩余候选 -> 剔除被点名项」即可收敛出 App 已授权集合(未认证请求,
  不登录/不产生授权),结果按 allowed/denied 返回并在设置页用绿/橙标签展示
- 缺少账号级读权限时不再失败:discover() 先试 /accounts,403 则改为列 token 可见的 /zones,
  用 zone 自带的 account_id 反推账户(展示名用第一个域名)
- 默认 scope 收敛为接入必需的三项(zone.read / workers-scripts.write /
  email-routing-rule.write),account-settings.read 改为可选:勾了才显示真实账号名
- 实测:线上 preflight 已 ok=true(Cloudflare 跳登录页),无需再改 App 即可「添加账号」
This commit is contained in:
bigcode
2026-09-30 16:26:43 +08:00
parent 1f4d45f719
commit 7a1625d575
9 changed files with 390 additions and 50 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ WmailHub/
- **Redirect URL**:`https://wmailhub.adx9.com/api/oauth/cloudflare/callback` - **Redirect URL**:`https://wmailhub.adx9.com/api/oauth/cloudflare/callback`
- **Client URI**:`https://wmailhub.adx9.com`(必须是 Console 站点域名),并按提示加 TXT 记录完成域名校验 - **Client URI**:`https://wmailhub.adx9.com`(必须是 Console 站点域名),并按提示加 TXT 记录完成域名校验
- **Promote to public**:私有 App 无法被授权 - **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 有两个配置位置,任一即可: 配好后到设置页点「检测授权配置」确认,再点账号页「添加账号」。Client ID / Secret 有两个配置位置,任一即可:
- **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署; - **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署;
+9
View File
@@ -330,8 +330,17 @@ export interface OAuthPreflightResult {
description: string | null; description: string | null;
hint: string | null; hint: string | null;
used: { client_id: string; redirect_uri: string; scope: string }; 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 授权端点是否接受当前配置(不产生授权) */ /** 授权配置自检:探测 Cloudflare 授权端点是否接受当前配置(不产生授权) */
export const oauthPreflight = () => api<OAuthPreflightResult>("/api/oauth/cloudflare/preflight"); export const oauthPreflight = () => api<OAuthPreflightResult>("/api/oauth/cloudflare/preflight");
@@ -19,6 +19,7 @@ import {
Popconfirm, Popconfirm,
Space, Space,
Tabs, Tabs,
Tag,
Typography, Typography,
} from "antd"; } from "antd";
import { import {
@@ -36,6 +37,8 @@ import {
getSettings, getSettings,
oauthPreflight, oauthPreflight,
putSettings, putSettings,
CF_OAUTH_SCOPES_DEFAULT,
CF_OAUTH_SCOPES_WITH_ACCOUNT_NAME,
type OAuthPreflightResult, type OAuthPreflightResult,
type SettingsInfo, type SettingsInfo,
} from "../api.ts"; } from "../api.ts";
@@ -386,9 +389,10 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
label="申请的 Scope(点号分隔,空格分隔多个)" label="申请的 Scope(点号分隔,空格分隔多个)"
extra={ extra={
<> <>
Cloudflare 的 OAuth scope 是<b>点号分隔</b>(account.read),冒号写法(account:read)会被判为 Cloudflare 的 OAuth scope 是<b>点号分隔</b>(zone.read),冒号写法(zone:read)是 wrangler 的
invalid_scope。必须与 Cloudflare OAuth App 中登记的 scope 完全一致;App 未 promote to public 第一方简写,第三方客户端会被判 invalid_scope。必须与 OAuth App 里勾选的权限一致;检测按钮会
或未授权这些 scope 时,点「检测授权配置」会直接报出来。 直接告诉你哪些已授权、哪些还要补勾。<b>account-settings.read 是可选的</b>:勾了才显示真实账号名,
不勾则用第一个域名代替,不影响接入。
</> </>
} }
> >
@@ -415,6 +419,61 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
</Space> </Space>
</Form> </Form>
{check && check.scopes.probed && (
<Card size="small" title="Scope 探测结果" style={{ width: "100%" }}>
<Space orientation="vertical" size="small" style={{ width: "100%" }}>
<div>
<Typography.Text type="secondary">已授权(可直接使用):</Typography.Text>{" "}
{check.scopes.allowed.length ? (
check.scopes.allowed.map((x) => <Tag key={x} color="green">{x}</Tag>)
) : (
<Typography.Text type="secondary">无</Typography.Text>
)}
</div>
<div>
<Typography.Text type="secondary">待勾选(在 OAuth App 的选择器里补上):</Typography.Text>{" "}
{check.scopes.denied.length ? (
check.scopes.denied.map((x) => <Tag key={x} color="orange">{x}</Tag>)
) : (
<Typography.Text type="success">无,App 已授权全部所需权限</Typography.Text>
)}
</div>
{check.scopes.note && <Typography.Text type="warning">{check.scopes.note}</Typography.Text>}
<Space>
<Button
size="small"
onClick={() => {
form.setFieldValue("scope", CF_OAUTH_SCOPES_DEFAULT);
message.success("已填入最小 scope(接入必需的三项)");
}}
>
填入最小 scope
</Button>
<Button
size="small"
onClick={() => {
form.setFieldValue("scope", CF_OAUTH_SCOPES_WITH_ACCOUNT_NAME);
message.success("已填入含账号名权限的 scope(需 App 里也勾了 Account Settings Read)");
}}
>
含账号名权限
</Button>
{check.scopes.allowed.length > 0 && (
<Button
size="small"
onClick={() => {
form.setFieldValue("scope", check.scopes.allowed.join(" "));
message.info("已填入 App 当前已授权的 scope");
}}
>
只填已授权的
</Button>
)}
</Space>
</Space>
</Card>
)}
{check && ( {check && (
<Alert <Alert
type={check.ok ? "success" : "error"} type={check.ok ? "success" : "error"}
@@ -428,6 +487,11 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
<> <>
{check.description && <div style={{ marginBottom: 6 }}>{check.description}</div>} {check.description && <div style={{ marginBottom: 6 }}>{check.description}</div>}
{check.hint && <div>{check.hint}</div>} {check.hint && <div>{check.hint}</div>}
{check.scopes.denied.length > 0 && (
<div style={{ marginTop: 6 }}>
下面「Scope 探测结果」列出的待勾选项,对应 OAuth App 选择器里的同名权限。
</div>
)}
<div className="muted" style={{ marginTop: 6 }}> <div className="muted" style={{ marginTop: 6 }}>
回调地址:{check.used.redirect_uri} · scope:{check.used.scope || "(空)"} 回调地址:{check.used.redirect_uri} · scope:{check.used.scope || "(空)"}
</div> </div>
+140 -24
View File
@@ -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"; export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback";
/** # 成员变量: OAuth 申请权限(docs/08 最小权限:账户与 Zone 读、Email Routing 写、Workers 脚本写) /** # 成员变量: 本应用实际需要的 scope(点号分隔;与 Cloudflare OAuth App 里勾选的权限对应)
* 注意:Cloudflare OAuth 的 scope 是**点号分隔**的(冒号形式不被接受,见 Cloudflare *
* "Create OAuth Client" API 文档:Colon-delimited scopes are not accepted)。 * 重要背景(实测结论):
* 冒号写法会让授权端点直接返回 error=invalid_scope。 * - Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.<verb>`;
* 这里只是默认值,实际请求的 scope 来自 settings.cf_oauth_scopes(可在设置页改)。 **/ * `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 = [ export const CF_OAUTH_SCOPES_DEFAULT = [
"account.read",
"zone.read", "zone.read",
"email_routing.write", "workers-scripts.write",
"workers_scripts.write", "email-routing-rule.write",
].join(" "); ].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 **/ /** # 成员变量: settings 表中保存 OAuth 客户端凭据的 key **/
export const CF_OAUTH_SETTING_KEYS = { export const CF_OAUTH_SETTING_KEYS = {
clientId: "cf_oauth_client_id", clientId: "cf_oauth_client_id",
@@ -155,6 +174,8 @@ export interface OAuthPreflight {
hint: string | null; hint: string | null;
/** 本次自检使用的参数 */ /** 本次自检使用的参数 */
used: { client_id: string; redirect_uri: string; scope: string }; 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<O
const redirectUri = oauthRedirectUri(env, requestOrigin); const redirectUri = oauthRedirectUri(env, requestOrigin);
const scope = await getOAuthScopes(env); const scope = await getOAuthScopes(env);
const used = { client_id: client?.clientId ?? "", redirect_uri: redirectUri, scope }; const used = { client_id: client?.clientId ?? "", redirect_uri: redirectUri, scope };
const noScopes = { allowed: [], denied: [], probed: false, note: null };
if (!client) { if (!client) {
return { return {
ok: false, ok: false,
@@ -175,6 +197,7 @@ export async function preflightOAuth(env: Env, requestOrigin: string): Promise<O
description: "尚未配置 Cloudflare OAuth 客户端", description: "尚未配置 Cloudflare OAuth 客户端",
hint: "先在下方填入 Client ID 与 Client Secret 并保存", hint: "先在下方填入 Client ID 与 Client Secret 并保存",
used, used,
scopes: noScopes,
}; };
} }
let resp: Response; let resp: Response;
@@ -191,6 +214,7 @@ export async function preflightOAuth(env: Env, requestOrigin: string): Promise<O
description: err instanceof Error ? err.message : String(err), description: err instanceof Error ? err.message : String(err),
hint: "Console 无法访问 dash.cloudflare.com,请检查出网", hint: "Console 无法访问 dash.cloudflare.com,请检查出网",
used, used,
scopes: noScopes,
}; };
} }
const location = resp.headers.get("location") ?? ""; const location = resp.headers.get("location") ?? "";
@@ -203,10 +227,15 @@ export async function preflightOAuth(env: Env, requestOrigin: string): Promise<O
if (isCallbackBounce || isCfErrorPage) { if (isCallbackBounce || isCfErrorPage) {
const error = target.searchParams.get("error"); const error = target.searchParams.get("error");
const description = target.searchParams.get("error_description"); const description = target.searchParams.get("error_description");
return { ok: false, location, error, description, hint: hintForOAuthError(error), used }; // 被拒的是 scope 时顺便探测出「已授权 / 待勾选」,省得用户猜选择器里该勾哪几项
const scopes =
error === "invalid_scope"
? await discoverAllowedScopes(client.clientId, redirectUri)
: { allowed: [], denied: [], probed: false, note: null };
return { ok: false, location, error, description, hint: hintForOAuthError(error), used, scopes };
} }
// 跳到登录/同意页 = client_id、redirect_uri、scope 全部被接受 // 跳到登录/同意页 = client_id、redirect_uri、scope 全部被接受
return { ok: true, location, error: null, description: null, hint: null, used }; return { ok: true, location, error: null, description: null, hint: null, used, scopes: noScopes };
} }
return { return {
ok: false, ok: false,
@@ -215,19 +244,78 @@ export async function preflightOAuth(env: Env, requestOrigin: string): Promise<O
description: `授权端点返回 HTTP ${resp.status}`, description: `授权端点返回 HTTP ${resp.status}`,
hint: "检查 Client ID 是否正确", hint: "检查 Client ID 是否正确",
used, used,
scopes: noScopes,
}; };
} }
/** # 函数
* scope 探测:授权端点在**显示同意页之前**就会校验 scope,被拒时通过 error_description 点名是哪一个。
* 于是可以逐轮「请求剩余候选 → 剔除被点名的那个」,收敛出该 App 实际已授权的集合。
* 全程无需登录/同意,不产生任何授权;每轮一个未认证请求。
* 返回值: allowed=已授权、denied=尚未授权(需在选择器里补勾)
*/
async function probeScopes(
clientId: string,
redirectUri: string,
): Promise<{ allowed: string[]; denied: string[]; error: string | null }> {
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<OAuthPreflight["scopes"]> {
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 授权错误翻译成可执行的处置建议 * 把 Cloudflare 授权错误翻译成可执行的处置建议
*/ */
function hintForOAuthError(error: string | null): string | null { function hintForOAuthError(error: string | null): string | null {
if (error === "invalid_scope") { if (error === "invalid_scope") {
return ( return (
"OAuth App 未授权本应用请求的 scope(或是冒号写法)。到 Cloudflare Dashboard → " + "OAuth App 不允许请求本应用需要的某些 scope。到 Cloudflare Dashboard → My Profile → OAuth Applications " +
"My Profile → OAuth Applications 编辑该应用:Client URI 填 Console 域名(如 https://wmailhub.adx9.com)、" + "编辑该应用:Client URI 填 Console 域名(如 https://wmailhub.adx9.com)、按提示加 TXT 记录完成域名校验、" +
"按提示加 TXT 记录完成域名校验、Promote to public,并在 scopes 中加入本应用请求的点号格式 scope。" + "Promote to public,并在选择器里勾选下方「待勾选」列出的权限。客户端注册的 scope 是上限而非默认," +
"也可在本页调整 scope 字段与 App 中登记的一致。" "没勾的权限即使账号有也不会生效。"
); );
} }
if (error === "invalid_request") { if (error === "invalid_request") {
@@ -265,14 +353,22 @@ export interface CFZone {
status: string; status: string;
} }
/** # 成员变量: 账号/域名发现结果 **/
export interface CFDiscovery {
/** 目标 Cloudflare 账户(id + 展示名) */
account: CFAccount;
/** 该账户(或 token 可见)下的域名 Zone */
zones: CFZone[];
/** 账号来源:accounts 接口,或因缺少账号级读权限而从 zone 的 account_id 反推 */
accountSource: "accounts" | "zones";
}
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/ /** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
export interface CFApi { export interface CFApi {
/** 用 code 换 access 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[]>; discover(accessToken: string): Promise<CFDiscovery>;
/** 发现账户下的域名 Zone */
listZones(accessToken: string, accountId: string): Promise<CFZone[]>;
/** 创建或更新账号级 Email Worker(幂等) */ /** 创建或更新账号级 Email Worker(幂等) */
putEmailWorker(opts: { putEmailWorker(opts: {
accessToken: string; accessToken: string;
@@ -345,20 +441,40 @@ export class RealCFApi implements CFApi {
return this.api<CFAccount[]>(token, "/accounts"); return this.api<CFAccount[]>(token, "/accounts");
} }
async listZones(token: string, accountId: string) { async listZones(token: string, accountId?: string) {
// 分页拉全账号下的 Zone(每页 50,最多 20 页) // 分页拉全 Zone(每页 50,最多 20 页);不给 accountId 时返回 token 可见的全部 Zone
const out: CFZone[] = []; const out: CFZone[] = [];
for (let page = 1; page <= 20; page++) { for (let page = 1; page <= 20; page++) {
const rows = await this.api<CFZone[]>( const query = new URLSearchParams({ per_page: "50", page: String(page) });
token, if (accountId) query.set("account.id", accountId);
`/zones?account.id=${encodeURIComponent(accountId)}&per_page=50&page=${page}`, const rows = await this.api<CFZone[]>(token, `/zones?${query.toString()}`);
);
out.push(...rows); out.push(...rows);
if (rows.length < 50) break; if (rows.length < 50) break;
} }
return out; return out;
} }
/** 账号 + 域名发现。
* 优先用 /accounts 拿账户名(需要账号级读权限);
* 没有该权限时退而求其次:直接列 token 可见的 Zone,用 zone 自带的 account_id 反推账户,
* 展示名用第一个域名代替 —— 这样只要授权了 zone.read 就能完成接入。 */
async discover(token: string): Promise<CFDiscovery> {
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: { async putEmailWorker(opts: {
accessToken: string; accessToken: string;
accountId: string; accountId: string;
+5 -8
View File
@@ -131,21 +131,18 @@ export async function provisionAccount(env: Env, input: ProvisionInput): Promise
// 1. OAuth 授权 // 1. OAuth 授权
await mark("oauth", "done"); await mark("oauth", "done");
// 2. Account/Zone 发现 // 2. Account/Zone 发现(没有账号级读权限时自动从 zone 的 account_id 反推)
await mark("discover", "running"); await mark("discover", "running");
const accounts = await cf.listAccounts(input.accessToken); const found = await cf.discover(input.accessToken);
if (accounts.length === 0) throw new Error("no cloudflare account accessible"); const cfAccount = found.account;
const cfAccount = input.cfAccountId const zones = found.zones;
? accounts.find((a) => a.id === input.cfAccountId) ?? accounts[0] // 自动创建的账号以 Cloudflare 账户名(或反推出的展示名)命名
: accounts[0];
// 自动创建的账号以 Cloudflare 账户名命名
const newName = account.name.startsWith("Cloudflare 账号") ? cfAccount.name : account.name; const newName = account.name.startsWith("Cloudflare 账号") ? cfAccount.name : account.name;
await env.DB.prepare( await env.DB.prepare(
"UPDATE cloudflare_accounts SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?", "UPDATE cloudflare_accounts SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?",
) )
.bind(cfAccount.id, newName, nowIso(), input.accountId) .bind(cfAccount.id, newName, nowIso(), input.accountId)
.run(); .run();
const zones = await cf.listZones(input.accessToken, cfAccount.id);
await mark("discover", "done"); await mark("discover", "done");
// 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱 // 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
+40 -7
View File
@@ -61,16 +61,34 @@ Cloudflare Dashboard 登记的回调地址),`GET /api/oauth/mode` 返回 `source
| Promote to public | 应用可见性从 private 提升为 public | 私有 App 不可被他人授权 | | Promote to public | 应用可见性从 private 提升为 public | 私有 App 不可被他人授权 |
| Scopes | 与 Console「设置 → Cloudflare 接入」里的 scope 字段**逐字一致** | `invalid_scope`: Client is not allowed to request scope '...' | | Scopes | 与 Console「设置 → Cloudflare 接入」里的 scope 字段**逐字一致** | `invalid_scope`: Client is not allowed to request scope '...' |
### scope 必须是点号分隔 ### scope 词表与选择器的对应关系
Cloudflare OAuth 的 scope 命名是 `account.read` 这种**点号**形式;冒号形式(`account:read`)不被接受, Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.<verb>`。`account:read` 这种冒号写法
授权端点会判为 `invalid_scope`。默认请求的四个: 是 **wrangler 的第一方简写**,第三方客户端请求会被一律拒绝(它只对第一方 client 生效)。权威列表是
`GET /client/v4/oauth/scopes`,选择器里是它的可勾选项。
```text 本应用默认请求这四个(已在 Cloudflare 侧逐个验证可被接受):
account.read zone.read email_routing.write workers_scripts.write
```
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` 与一条处置建议。 `error_description` 与一条处置建议。
设置页「Cloudflare 接入」tab 的「检测授权配置」按钮就是调它。纯只读探测,不会产生任何授权。 设置页「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 选择器里补上的权限。
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "wmailhub", "name": "wmailhub",
"version": "2.3.1", "version": "2.3.2",
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)", "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
"private": true, "private": true,
"type": "module", "type": "module",
+41 -4
View File
@@ -29,8 +29,14 @@ export interface CfMockOptions {
accessToken?: string; accessToken?: string;
/** token 端点 HTTP 状态(测失败分支用) **/ /** token 端点 HTTP 状态(测失败分支用) **/
tokenStatus?: number; tokenStatus?: number;
/** 授权端点(dash.cloudflare.com/oauth2/auth)的 Location 跳转目标;不传则跳登录页 **/ /** 授权端点(dash.cloudflare.com/oauth2/auth)的固定 Location 跳转目标;不传则按 scopesAllowed 判定 **/
authorizeRedirect?: string; authorizeRedirect?: string;
/** 授权端点允许的 scope 集合;其余一律 invalid_scope 并点名第一个被拒的(不传 = 全拒) **/
scopesAllowed?: string[];
/** 授权端点固定错误(走 Cloudflare 自己的 /oauth/error 页,不弹回回调地址) **/
authorizeError?: string;
/** /accounts 返回 403(模拟只授权了 zone 读、没有账号级读权限) */
failAccounts?: boolean;
} }
/** # 成员变量: Cloudflare API 桩句柄 **/ /** # 成员变量: Cloudflare API 桩句柄 **/
@@ -47,6 +53,7 @@ export interface CfMock {
const TOKEN_URL = "https://dash.cloudflare.com/oauth2/token"; const TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
const CF_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth"; 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"; 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)) { if (url.startsWith(CF_AUTHORIZE_URL)) {
const target = opts.authorizeRedirect ?? "https://dash.cloudflare.com/login"; if (opts.authorizeError) {
return new Response(null, { status: 302, headers: { Location: target } }); 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 (url === TOKEN_URL) {
if ((opts.tokenStatus ?? 200) !== 200) { if ((opts.tokenStatus ?? 200) !== 200) {
@@ -106,7 +134,16 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
headers: { "Content-Type": "application/json" }, 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.startsWith(`${API_BASE}/zones`)) {
if (url.includes("/email/routing/rules/catch_all")) return json({ id: "catch-all-rule" }); if (url.includes("/email/routing/rules/catch_all")) return json({ id: "catch-all-rule" });
if (url.includes("/email/routing")) return json({ enabled: true }); if (url.includes("/email/routing")) return json({ enabled: true });
+86 -2
View File
@@ -77,7 +77,7 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
// 真实发出的 Cloudflare 调用:换 token -> 账户发现 -> 域名发现 -> 上传 Email Worker 脚本 // 真实发出的 Cloudflare 调用:换 token -> 账户发现 -> 域名发现 -> 上传 Email Worker 脚本
expect(cf.hit("POST", "/oauth2/token")).toBe(true); expect(cf.hit("POST", "/oauth2/token")).toBe(true);
expect(cf.hit("GET", "/accounts")).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); expect(cf.hit("PUT", "/workers/scripts/")).toBe(true);
// token 兑换请求带真实 code 与 client 凭据 // token 兑换请求带真实 code 与 client 凭据
const tokenBody = cf.bodyOf("POST", "/oauth2/token") ?? ""; 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"); 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 () => { test("state 一次性:重放授权回调被拒(OAuth CSRF 防护)", async () => {
const t = await makeTestEnv(); const t = await makeTestEnv();
const cookie = await loginCookie(t); 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") await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
).json()) as { url: string }; ).json()) as { url: string };
const scope = new URL(start.url).searchParams.get("scope") ?? ""; 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(":"); expect(scope).not.toContain(":");
// settings 里自定义 scope 后生效 // settings 里自定义 scope 后生效
@@ -293,6 +330,7 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
hint: string | null; hint: string | null;
description?: string | null; description?: string | null;
used: { redirect_uri: string }; used: { redirect_uri: string };
scopes: { allowed: string[]; denied: string[]; probed: boolean; note: string | null };
}; };
let res = (await ( let res = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight") 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.error).toBe("invalid_request");
expect(res.hint).toContain("Redirect URL"); 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" }); installCloudflareMock({ authorizeRedirect: "https://dash.cloudflare.com/login" });
res = (await ( res = (await (