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:
+140
-24
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user