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
+9
View File
@@ -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<OAuthPreflightResult>("/api/oauth/cloudflare/preflight");
@@ -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 是<b>点号分隔</b>(account.read),冒号写法(account:read)会被判为
invalid_scope。必须与 Cloudflare OAuth App 中登记的 scope 完全一致;App 未 promote to public
或未授权这些 scope 时,点「检测授权配置」会直接报出来。
Cloudflare 的 OAuth scope 是<b>点号分隔</b>(zone.read),冒号写法(zone:read)是 wrangler 的
第一方简写,第三方客户端会被判 invalid_scope。必须与 OAuth App 里勾选的权限一致;检测按钮会
直接告诉你哪些已授权、哪些还要补勾。<b>account-settings.read 是可选的</b>:勾了才显示真实账号名,
不勾则用第一个域名代替,不影响接入。
</>
}
>
@@ -415,6 +419,61 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
</Space>
</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 && (
<Alert
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.hint && <div>{check.hint}</div>}
{check.scopes.denied.length > 0 && (
<div style={{ marginTop: 6 }}>
下面「Scope 探测结果」列出的待勾选项,对应 OAuth App 选择器里的同名权限。
</div>
)}
<div className="muted" style={{ marginTop: 6 }}>
回调地址:{check.used.redirect_uri} · scope:{check.used.scope || "(空)"}
</div>