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
+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";
/** # 成员变量: 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 是 **点号分隔** 的 `<group>.<verb>`;
* `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<O
const redirectUri = oauthRedirectUri(env, requestOrigin);
const scope = await getOAuthScopes(env);
const used = { client_id: client?.clientId ?? "", redirect_uri: redirectUri, scope };
const noScopes = { allowed: [], denied: [], probed: false, note: null };
if (!client) {
return {
ok: false,
@@ -175,6 +197,7 @@ export async function preflightOAuth(env: Env, requestOrigin: string): Promise<O
description: "尚未配置 Cloudflare OAuth 客户端",
hint: "先在下方填入 Client ID 与 Client Secret 并保存",
used,
scopes: noScopes,
};
}
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),
hint: "Console 无法访问 dash.cloudflare.com,请检查出网",
used,
scopes: noScopes,
};
}
const location = resp.headers.get("location") ?? "";
@@ -203,10 +227,15 @@ export async function preflightOAuth(env: Env, requestOrigin: string): Promise<O
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 };
// 被拒的是 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 全部被接受
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 {
ok: false,
@@ -215,19 +244,78 @@ export async function preflightOAuth(env: Env, requestOrigin: string): Promise<O
description: `授权端点返回 HTTP ${resp.status}`,
hint: "检查 Client ID 是否正确",
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 授权错误翻译成可执行的处置建议
*/
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<CFAccount[]>;
/** 发现账户下的域名 Zone */
listZones(accessToken: string, accountId: string): Promise<CFZone[]>;
/** 发现 token 可访问的账户 + 其下域名 */
discover(accessToken: string): Promise<CFDiscovery>;
/** 创建或更新账号级 Email Worker(幂等) */
putEmailWorker(opts: {
accessToken: string;
@@ -345,20 +441,40 @@ export class RealCFApi implements CFApi {
return this.api<CFAccount[]>(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<CFZone[]>(
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<CFZone[]>(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<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: {
accessToken: string;
accountId: string;