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:
@@ -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>
|
||||
|
||||
+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;
|
||||
|
||||
@@ -131,21 +131,18 @@ export async function provisionAccount(env: Env, input: ProvisionInput): Promise
|
||||
// 1. OAuth 授权
|
||||
await mark("oauth", "done");
|
||||
|
||||
// 2. Account/Zone 发现
|
||||
// 2. Account/Zone 发现(没有账号级读权限时自动从 zone 的 account_id 反推)
|
||||
await mark("discover", "running");
|
||||
const accounts = await cf.listAccounts(input.accessToken);
|
||||
if (accounts.length === 0) throw new Error("no cloudflare account accessible");
|
||||
const cfAccount = input.cfAccountId
|
||||
? accounts.find((a) => a.id === input.cfAccountId) ?? accounts[0]
|
||||
: accounts[0];
|
||||
// 自动创建的账号以 Cloudflare 账户名命名
|
||||
const found = await cf.discover(input.accessToken);
|
||||
const cfAccount = found.account;
|
||||
const zones = found.zones;
|
||||
// 自动创建的账号以 Cloudflare 账户名(或反推出的展示名)命名
|
||||
const newName = account.name.startsWith("Cloudflare 账号") ? cfAccount.name : account.name;
|
||||
await env.DB.prepare(
|
||||
"UPDATE cloudflare_accounts SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(cfAccount.id, newName, nowIso(), input.accountId)
|
||||
.run();
|
||||
const zones = await cf.listZones(input.accessToken, cfAccount.id);
|
||||
await mark("discover", "done");
|
||||
|
||||
// 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
|
||||
|
||||
Reference in New Issue
Block a user