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
+41 -4
View File
@@ -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 });