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:
@@ -129,7 +129,7 @@ WmailHub/
|
|||||||
- **Redirect URL**:`https://wmailhub.adx9.com/api/oauth/cloudflare/callback`
|
- **Redirect URL**:`https://wmailhub.adx9.com/api/oauth/cloudflare/callback`
|
||||||
- **Client URI**:`https://wmailhub.adx9.com`(必须是 Console 站点域名),并按提示加 TXT 记录完成域名校验
|
- **Client URI**:`https://wmailhub.adx9.com`(必须是 Console 站点域名),并按提示加 TXT 记录完成域名校验
|
||||||
- **Promote to public**:私有 App 无法被授权
|
- **Promote to public**:私有 App 无法被授权
|
||||||
- **Scopes**:`account.read zone.read email_routing.write workers_scripts.write`(**点号分隔**,冒号写法会被判 invalid_scope)
|
- **Scopes**(`zone.read` / `workers-scripts.write` / `email-routing-rule.write` / `account-settings.read`):**点号分隔**,冒号写法(`account:read`)是 wrangler 的第一方简写,第三方 client 会被拒;缺 `account-settings.read` 也能接入(自动从域名反推账号)
|
||||||
|
|
||||||
配好后到设置页点「检测授权配置」确认,再点账号页「添加账号」。Client ID / Secret 有两个配置位置,任一即可:
|
配好后到设置页点「检测授权配置」确认,再点账号页「添加账号」。Client ID / Secret 有两个配置位置,任一即可:
|
||||||
- **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署;
|
- **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署;
|
||||||
|
|||||||
@@ -330,8 +330,17 @@ export interface OAuthPreflightResult {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
hint: string | null;
|
hint: string | null;
|
||||||
used: { client_id: string; redirect_uri: string; scope: string };
|
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 授权端点是否接受当前配置(不产生授权) */
|
/** 授权配置自检:探测 Cloudflare 授权端点是否接受当前配置(不产生授权) */
|
||||||
export const oauthPreflight = () => api<OAuthPreflightResult>("/api/oauth/cloudflare/preflight");
|
export const oauthPreflight = () => api<OAuthPreflightResult>("/api/oauth/cloudflare/preflight");
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
Space,
|
Space,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,8 @@ import {
|
|||||||
getSettings,
|
getSettings,
|
||||||
oauthPreflight,
|
oauthPreflight,
|
||||||
putSettings,
|
putSettings,
|
||||||
|
CF_OAUTH_SCOPES_DEFAULT,
|
||||||
|
CF_OAUTH_SCOPES_WITH_ACCOUNT_NAME,
|
||||||
type OAuthPreflightResult,
|
type OAuthPreflightResult,
|
||||||
type SettingsInfo,
|
type SettingsInfo,
|
||||||
} from "../api.ts";
|
} from "../api.ts";
|
||||||
@@ -386,9 +389,10 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
|
|||||||
label="申请的 Scope(点号分隔,空格分隔多个)"
|
label="申请的 Scope(点号分隔,空格分隔多个)"
|
||||||
extra={
|
extra={
|
||||||
<>
|
<>
|
||||||
Cloudflare 的 OAuth scope 是<b>点号分隔</b>(account.read),冒号写法(account:read)会被判为
|
Cloudflare 的 OAuth scope 是<b>点号分隔</b>(zone.read),冒号写法(zone:read)是 wrangler 的
|
||||||
invalid_scope。必须与 Cloudflare OAuth App 中登记的 scope 完全一致;App 未 promote to public
|
第一方简写,第三方客户端会被判 invalid_scope。必须与 OAuth App 里勾选的权限一致;检测按钮会
|
||||||
或未授权这些 scope 时,点「检测授权配置」会直接报出来。
|
直接告诉你哪些已授权、哪些还要补勾。<b>account-settings.read 是可选的</b>:勾了才显示真实账号名,
|
||||||
|
不勾则用第一个域名代替,不影响接入。
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -415,6 +419,61 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
|
|||||||
</Space>
|
</Space>
|
||||||
</Form>
|
</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 && (
|
{check && (
|
||||||
<Alert
|
<Alert
|
||||||
type={check.ok ? "success" : "error"}
|
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.description && <div style={{ marginBottom: 6 }}>{check.description}</div>}
|
||||||
{check.hint && <div>{check.hint}</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 }}>
|
<div className="muted" style={{ marginTop: 6 }}>
|
||||||
回调地址:{check.used.redirect_uri} · scope:{check.used.scope || "(空)"}
|
回调地址:{check.used.redirect_uri} · scope:{check.used.scope || "(空)"}
|
||||||
</div>
|
</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";
|
export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback";
|
||||||
|
|
||||||
/** # 成员变量: OAuth 申请权限(docs/08 最小权限:账户与 Zone 读、Email Routing 写、Workers 脚本写)
|
/** # 成员变量: 本应用实际需要的 scope(点号分隔;与 Cloudflare OAuth App 里勾选的权限对应)
|
||||||
* 注意:Cloudflare OAuth 的 scope 是**点号分隔**的(冒号形式不被接受,见 Cloudflare
|
*
|
||||||
* "Create OAuth Client" API 文档:Colon-delimited scopes are not accepted)。
|
* 重要背景(实测结论):
|
||||||
* 冒号写法会让授权端点直接返回 error=invalid_scope。
|
* - Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.<verb>`;
|
||||||
* 这里只是默认值,实际请求的 scope 来自 settings.cf_oauth_scopes(可在设置页改)。 **/
|
* `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 = [
|
export const CF_OAUTH_SCOPES_DEFAULT = [
|
||||||
"account.read",
|
|
||||||
"zone.read",
|
"zone.read",
|
||||||
"email_routing.write",
|
"workers-scripts.write",
|
||||||
"workers_scripts.write",
|
"email-routing-rule.write",
|
||||||
].join(" ");
|
].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 **/
|
/** # 成员变量: settings 表中保存 OAuth 客户端凭据的 key **/
|
||||||
export const CF_OAUTH_SETTING_KEYS = {
|
export const CF_OAUTH_SETTING_KEYS = {
|
||||||
clientId: "cf_oauth_client_id",
|
clientId: "cf_oauth_client_id",
|
||||||
@@ -155,6 +174,8 @@ export interface OAuthPreflight {
|
|||||||
hint: string | null;
|
hint: string | null;
|
||||||
/** 本次自检使用的参数 */
|
/** 本次自检使用的参数 */
|
||||||
used: { client_id: string; redirect_uri: string; scope: string };
|
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 redirectUri = oauthRedirectUri(env, requestOrigin);
|
||||||
const scope = await getOAuthScopes(env);
|
const scope = await getOAuthScopes(env);
|
||||||
const used = { client_id: client?.clientId ?? "", redirect_uri: redirectUri, scope };
|
const used = { client_id: client?.clientId ?? "", redirect_uri: redirectUri, scope };
|
||||||
|
const noScopes = { allowed: [], denied: [], probed: false, note: null };
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -175,6 +197,7 @@ export async function preflightOAuth(env: Env, requestOrigin: string): Promise<O
|
|||||||
description: "尚未配置 Cloudflare OAuth 客户端",
|
description: "尚未配置 Cloudflare OAuth 客户端",
|
||||||
hint: "先在下方填入 Client ID 与 Client Secret 并保存",
|
hint: "先在下方填入 Client ID 与 Client Secret 并保存",
|
||||||
used,
|
used,
|
||||||
|
scopes: noScopes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let resp: Response;
|
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),
|
description: err instanceof Error ? err.message : String(err),
|
||||||
hint: "Console 无法访问 dash.cloudflare.com,请检查出网",
|
hint: "Console 无法访问 dash.cloudflare.com,请检查出网",
|
||||||
used,
|
used,
|
||||||
|
scopes: noScopes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const location = resp.headers.get("location") ?? "";
|
const location = resp.headers.get("location") ?? "";
|
||||||
@@ -203,10 +227,15 @@ export async function preflightOAuth(env: Env, requestOrigin: string): Promise<O
|
|||||||
if (isCallbackBounce || isCfErrorPage) {
|
if (isCallbackBounce || isCfErrorPage) {
|
||||||
const error = target.searchParams.get("error");
|
const error = target.searchParams.get("error");
|
||||||
const description = target.searchParams.get("error_description");
|
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 全部被接受
|
// 跳到登录/同意页 = 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 {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -215,19 +244,78 @@ export async function preflightOAuth(env: Env, requestOrigin: string): Promise<O
|
|||||||
description: `授权端点返回 HTTP ${resp.status}`,
|
description: `授权端点返回 HTTP ${resp.status}`,
|
||||||
hint: "检查 Client ID 是否正确",
|
hint: "检查 Client ID 是否正确",
|
||||||
used,
|
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 授权错误翻译成可执行的处置建议
|
* 把 Cloudflare 授权错误翻译成可执行的处置建议
|
||||||
*/
|
*/
|
||||||
function hintForOAuthError(error: string | null): string | null {
|
function hintForOAuthError(error: string | null): string | null {
|
||||||
if (error === "invalid_scope") {
|
if (error === "invalid_scope") {
|
||||||
return (
|
return (
|
||||||
"OAuth App 未授权本应用请求的 scope(或是冒号写法)。到 Cloudflare Dashboard → " +
|
"OAuth App 不允许请求本应用需要的某些 scope。到 Cloudflare Dashboard → My Profile → OAuth Applications " +
|
||||||
"My Profile → OAuth Applications 编辑该应用:Client URI 填 Console 域名(如 https://wmailhub.adx9.com)、" +
|
"编辑该应用:Client URI 填 Console 域名(如 https://wmailhub.adx9.com)、按提示加 TXT 记录完成域名校验、" +
|
||||||
"按提示加 TXT 记录完成域名校验、Promote to public,并在 scopes 中加入本应用请求的点号格式 scope。" +
|
"Promote to public,并在选择器里勾选下方「待勾选」列出的权限。客户端注册的 scope 是上限而非默认," +
|
||||||
"也可在本页调整 scope 字段与 App 中登记的一致。"
|
"没勾的权限即使账号有也不会生效。"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (error === "invalid_request") {
|
if (error === "invalid_request") {
|
||||||
@@ -265,14 +353,22 @@ export interface CFZone {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** # 成员变量: 账号/域名发现结果 **/
|
||||||
|
export interface CFDiscovery {
|
||||||
|
/** 目标 Cloudflare 账户(id + 展示名) */
|
||||||
|
account: CFAccount;
|
||||||
|
/** 该账户(或 token 可见)下的域名 Zone */
|
||||||
|
zones: CFZone[];
|
||||||
|
/** 账号来源:accounts 接口,或因缺少账号级读权限而从 zone 的 account_id 反推 */
|
||||||
|
accountSource: "accounts" | "zones";
|
||||||
|
}
|
||||||
|
|
||||||
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
|
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
|
||||||
export interface CFApi {
|
export interface CFApi {
|
||||||
/** 用 code 换 access token */
|
/** 用 code 换 access token */
|
||||||
exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>;
|
exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>;
|
||||||
/** 发现 token 可访问的账户 */
|
/** 发现 token 可访问的账户 + 其下域名 */
|
||||||
listAccounts(accessToken: string): Promise<CFAccount[]>;
|
discover(accessToken: string): Promise<CFDiscovery>;
|
||||||
/** 发现账户下的域名 Zone */
|
|
||||||
listZones(accessToken: string, accountId: string): Promise<CFZone[]>;
|
|
||||||
/** 创建或更新账号级 Email Worker(幂等) */
|
/** 创建或更新账号级 Email Worker(幂等) */
|
||||||
putEmailWorker(opts: {
|
putEmailWorker(opts: {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
@@ -345,20 +441,40 @@ export class RealCFApi implements CFApi {
|
|||||||
return this.api<CFAccount[]>(token, "/accounts");
|
return this.api<CFAccount[]>(token, "/accounts");
|
||||||
}
|
}
|
||||||
|
|
||||||
async listZones(token: string, accountId: string) {
|
async listZones(token: string, accountId?: string) {
|
||||||
// 分页拉全账号下的 Zone(每页 50,最多 20 页)
|
// 分页拉全 Zone(每页 50,最多 20 页);不给 accountId 时返回 token 可见的全部 Zone
|
||||||
const out: CFZone[] = [];
|
const out: CFZone[] = [];
|
||||||
for (let page = 1; page <= 20; page++) {
|
for (let page = 1; page <= 20; page++) {
|
||||||
const rows = await this.api<CFZone[]>(
|
const query = new URLSearchParams({ per_page: "50", page: String(page) });
|
||||||
token,
|
if (accountId) query.set("account.id", accountId);
|
||||||
`/zones?account.id=${encodeURIComponent(accountId)}&per_page=50&page=${page}`,
|
const rows = await this.api<CFZone[]>(token, `/zones?${query.toString()}`);
|
||||||
);
|
|
||||||
out.push(...rows);
|
out.push(...rows);
|
||||||
if (rows.length < 50) break;
|
if (rows.length < 50) break;
|
||||||
}
|
}
|
||||||
return out;
|
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: {
|
async putEmailWorker(opts: {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|||||||
@@ -131,21 +131,18 @@ export async function provisionAccount(env: Env, input: ProvisionInput): Promise
|
|||||||
// 1. OAuth 授权
|
// 1. OAuth 授权
|
||||||
await mark("oauth", "done");
|
await mark("oauth", "done");
|
||||||
|
|
||||||
// 2. Account/Zone 发现
|
// 2. Account/Zone 发现(没有账号级读权限时自动从 zone 的 account_id 反推)
|
||||||
await mark("discover", "running");
|
await mark("discover", "running");
|
||||||
const accounts = await cf.listAccounts(input.accessToken);
|
const found = await cf.discover(input.accessToken);
|
||||||
if (accounts.length === 0) throw new Error("no cloudflare account accessible");
|
const cfAccount = found.account;
|
||||||
const cfAccount = input.cfAccountId
|
const zones = found.zones;
|
||||||
? accounts.find((a) => a.id === input.cfAccountId) ?? accounts[0]
|
// 自动创建的账号以 Cloudflare 账户名(或反推出的展示名)命名
|
||||||
: accounts[0];
|
|
||||||
// 自动创建的账号以 Cloudflare 账户名命名
|
|
||||||
const newName = account.name.startsWith("Cloudflare 账号") ? cfAccount.name : account.name;
|
const newName = account.name.startsWith("Cloudflare 账号") ? cfAccount.name : account.name;
|
||||||
await env.DB.prepare(
|
await env.DB.prepare(
|
||||||
"UPDATE cloudflare_accounts SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?",
|
"UPDATE cloudflare_accounts SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(cfAccount.id, newName, nowIso(), input.accountId)
|
.bind(cfAccount.id, newName, nowIso(), input.accountId)
|
||||||
.run();
|
.run();
|
||||||
const zones = await cf.listZones(input.accessToken, cfAccount.id);
|
|
||||||
await mark("discover", "done");
|
await mark("discover", "done");
|
||||||
|
|
||||||
// 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
|
// 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
|
||||||
|
|||||||
@@ -61,16 +61,34 @@ Cloudflare Dashboard 登记的回调地址),`GET /api/oauth/mode` 返回 `source
|
|||||||
| Promote to public | 应用可见性从 private 提升为 public | 私有 App 不可被他人授权 |
|
| Promote to public | 应用可见性从 private 提升为 public | 私有 App 不可被他人授权 |
|
||||||
| Scopes | 与 Console「设置 → Cloudflare 接入」里的 scope 字段**逐字一致** | `invalid_scope`: Client is not allowed to request scope '...' |
|
| Scopes | 与 Console「设置 → Cloudflare 接入」里的 scope 字段**逐字一致** | `invalid_scope`: Client is not allowed to request scope '...' |
|
||||||
|
|
||||||
### scope 必须是点号分隔
|
### scope 词表与选择器的对应关系
|
||||||
|
|
||||||
Cloudflare OAuth 的 scope 命名是 `account.read` 这种**点号**形式;冒号形式(`account:read`)不被接受,
|
Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.<verb>`。`account:read` 这种冒号写法
|
||||||
授权端点会判为 `invalid_scope`。默认请求的四个:
|
是 **wrangler 的第一方简写**,第三方客户端请求会被一律拒绝(它只对第一方 client 生效)。权威列表是
|
||||||
|
`GET /client/v4/oauth/scopes`,选择器里是它的可勾选项。
|
||||||
|
|
||||||
```text
|
本应用默认请求这四个(已在 Cloudflare 侧逐个验证可被接受):
|
||||||
account.read zone.read email_routing.write workers_scripts.write
|
|
||||||
```
|
|
||||||
|
|
||||||
Console 允许在设置里改这个字段,以便与 App 中登记的值保持一致(`settings.cf_oauth_scopes`)。
|
| scope id | 选择器里的名称 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `account-settings.read` | Account Settings Read | 列出账号(拿账号名) |
|
||||||
|
| `zone.read` | Zone Read | 发现该账号下的域名 |
|
||||||
|
| `workers-scripts.write` | Workers Scripts Edit | 上传账号级 Email Worker 脚本 |
|
||||||
|
| `email-routing-rule.write` | Email Routing Rules Edit | 开启 Email Routing + 配置 catch-all 规则 |
|
||||||
|
|
||||||
|
两个容易踩的点:
|
||||||
|
|
||||||
|
- **Email Routing 在选择器里是四个独立权限**(`Email Routing Account Rules` / `Addresses` /
|
||||||
|
`Rules` / `Suppressions`),不是笼统的"email routing 写"。改 catch-all 规则对应上面表里的
|
||||||
|
`email-routing-rule.write`。
|
||||||
|
- **客户端注册的 scope 是上限,不是默认值**。App 里没勾的权限,即使账号本身有,授权时也不会生效;
|
||||||
|
反过来请求里不写某个 scope,即使 App 勾了也不会被授予。
|
||||||
|
|
||||||
|
### 缺少账号级读权限时的降级(2026-09-30 实测后加入)
|
||||||
|
|
||||||
|
若 App 没有勾 `account-settings.read`,`GET /accounts` 会 403。Console 会自动降级:直接列 token 可见的
|
||||||
|
`/zones`,用 zone 自带的 `account_id` 反推目标账户,账号展示名用第一个域名代替(如 `example.com 等 3 个域名`)。
|
||||||
|
因此**只勾 `zone.read` + `workers-scripts.write` + `email-routing-rule.write` 也能完成接入**,只是账号名显示为域名。
|
||||||
|
|
||||||
## 授权配置自检(不用开浏览器)
|
## 授权配置自检(不用开浏览器)
|
||||||
|
|
||||||
@@ -81,3 +99,18 @@ Console 允许在设置里改这个字段,以便与 App 中登记的值保持一
|
|||||||
`error_description` 与一条处置建议。
|
`error_description` 与一条处置建议。
|
||||||
|
|
||||||
设置页「Cloudflare 接入」tab 的「检测授权配置」按钮就是调它。纯只读探测,不会产生任何授权。
|
设置页「Cloudflare 接入」tab 的「检测授权配置」按钮就是调它。纯只读探测,不会产生任何授权。
|
||||||
|
|
||||||
|
### scope 探测:直接算出"哪些已授权、哪些还要勾"
|
||||||
|
|
||||||
|
授权端点在**显示同意页之前**就校验 scope,拒绝时通过 `error_description` **点名**是哪一个
|
||||||
|
(`... not allowed to request scope 'xxx'`)。于是可以逐轮「请求剩余候选 → 剔除被点名的那一个」,
|
||||||
|
收敛出该 App 实际持有的 scope 集合——每轮只是一个未认证请求,无需登录、无需同意、不产生任何授权。
|
||||||
|
|
||||||
|
`preflight` 在收到 `invalid_scope` 时会自动跑完这个探测,并在结果里返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scopes": { "allowed": ["zone.read", "workers-scripts.write"],
|
||||||
|
"denied": ["account-settings.read"], "probed": true, "note": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
设置页把两列分别渲染成绿色/橙色标签,「待勾选」列就是需要在 OAuth App 选择器里补上的权限。
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wmailhub",
|
"name": "wmailhub",
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+41
-4
@@ -29,8 +29,14 @@ export interface CfMockOptions {
|
|||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
/** token 端点 HTTP 状态(测失败分支用) **/
|
/** token 端点 HTTP 状态(测失败分支用) **/
|
||||||
tokenStatus?: number;
|
tokenStatus?: number;
|
||||||
/** 授权端点(dash.cloudflare.com/oauth2/auth)的 Location 跳转目标;不传则跳登录页 **/
|
/** 授权端点(dash.cloudflare.com/oauth2/auth)的固定 Location 跳转目标;不传则按 scopesAllowed 判定 **/
|
||||||
authorizeRedirect?: string;
|
authorizeRedirect?: string;
|
||||||
|
/** 授权端点允许的 scope 集合;其余一律 invalid_scope 并点名第一个被拒的(不传 = 全拒) **/
|
||||||
|
scopesAllowed?: string[];
|
||||||
|
/** 授权端点固定错误(走 Cloudflare 自己的 /oauth/error 页,不弹回回调地址) **/
|
||||||
|
authorizeError?: string;
|
||||||
|
/** /accounts 返回 403(模拟只授权了 zone 读、没有账号级读权限) */
|
||||||
|
failAccounts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 成员变量: Cloudflare API 桩句柄 **/
|
/** # 成员变量: Cloudflare API 桩句柄 **/
|
||||||
@@ -47,6 +53,7 @@ export interface CfMock {
|
|||||||
|
|
||||||
const TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
|
const TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
|
||||||
const CF_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
|
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";
|
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)) {
|
if (url.startsWith(CF_AUTHORIZE_URL)) {
|
||||||
const target = opts.authorizeRedirect ?? "https://dash.cloudflare.com/login";
|
if (opts.authorizeError) {
|
||||||
return new Response(null, { status: 302, headers: { Location: target } });
|
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 (url === TOKEN_URL) {
|
||||||
if ((opts.tokenStatus ?? 200) !== 200) {
|
if ((opts.tokenStatus ?? 200) !== 200) {
|
||||||
@@ -106,7 +134,16 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
|||||||
headers: { "Content-Type": "application/json" },
|
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.startsWith(`${API_BASE}/zones`)) {
|
||||||
if (url.includes("/email/routing/rules/catch_all")) return json({ id: "catch-all-rule" });
|
if (url.includes("/email/routing/rules/catch_all")) return json({ id: "catch-all-rule" });
|
||||||
if (url.includes("/email/routing")) return json({ enabled: true });
|
if (url.includes("/email/routing")) return json({ enabled: true });
|
||||||
|
|||||||
+86
-2
@@ -77,7 +77,7 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
// 真实发出的 Cloudflare 调用:换 token -> 账户发现 -> 域名发现 -> 上传 Email Worker 脚本
|
// 真实发出的 Cloudflare 调用:换 token -> 账户发现 -> 域名发现 -> 上传 Email Worker 脚本
|
||||||
expect(cf.hit("POST", "/oauth2/token")).toBe(true);
|
expect(cf.hit("POST", "/oauth2/token")).toBe(true);
|
||||||
expect(cf.hit("GET", "/accounts")).toBe(true);
|
expect(cf.hit("GET", "/accounts")).toBe(true);
|
||||||
expect(cf.hit("GET", "/zones?account.id=")).toBe(true);
|
expect(cf.calls.some((c) => c.path.startsWith("/zones?") && c.path.includes("account.id="))).toBe(true);
|
||||||
expect(cf.hit("PUT", "/workers/scripts/")).toBe(true);
|
expect(cf.hit("PUT", "/workers/scripts/")).toBe(true);
|
||||||
// token 兑换请求带真实 code 与 client 凭据
|
// token 兑换请求带真实 code 与 client 凭据
|
||||||
const tokenBody = cf.bodyOf("POST", "/oauth2/token") ?? "";
|
const tokenBody = cf.bodyOf("POST", "/oauth2/token") ?? "";
|
||||||
@@ -121,6 +121,42 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
expect(conn!.encrypted_access_token).not.toContain("cf-oauth-access-token-xyz");
|
expect(conn!.encrypted_access_token).not.toContain("cf-oauth-access-token-xyz");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("缺少账号级读权限时:从 zone 的 account_id 反推账户,接入照样完成", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
// /accounts 返回 403(没有 account 读取权限),zones 仍可用且带 account_id
|
||||||
|
const cf = installCloudflareMock({
|
||||||
|
zones: [
|
||||||
|
{ id: "zone-a", name: "alpha.com", account_id: "cf-acct-from-zone", status: "active" },
|
||||||
|
{ id: "zone-b", name: "beta.com", account_id: "cf-acct-from-zone", status: "active" },
|
||||||
|
],
|
||||||
|
failAccounts: true,
|
||||||
|
});
|
||||||
|
mock = cf;
|
||||||
|
const { state } = (await (
|
||||||
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||||
|
).json()) as { state: string };
|
||||||
|
const resp = await t.app.fetch(
|
||||||
|
new Request(`http://localhost/api/oauth/cloudflare/callback?code=c1&state=${state}`),
|
||||||
|
t.env,
|
||||||
|
);
|
||||||
|
expect(resp.status).toBe(302);
|
||||||
|
expect(resp.headers.get("Location")).toContain("ok=1");
|
||||||
|
|
||||||
|
const accountId = new URL("http://x" + resp.headers.get("Location")).searchParams.get("provision")!;
|
||||||
|
const detail = (await (
|
||||||
|
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}`)
|
||||||
|
).json()) as {
|
||||||
|
account: { status: string; cloudflare_account_id: string | null; name: string };
|
||||||
|
domains: { name: string }[];
|
||||||
|
};
|
||||||
|
expect(detail.account.status).toBe("active");
|
||||||
|
expect(detail.account.cloudflare_account_id).toBe("cf-acct-from-zone");
|
||||||
|
// 展示名用第一个域名 + 域名数量
|
||||||
|
expect(detail.account.name).toBe("alpha.com 等 2 个域名");
|
||||||
|
expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["alpha.com", "beta.com"]));
|
||||||
|
});
|
||||||
|
|
||||||
test("state 一次性:重放授权回调被拒(OAuth CSRF 防护)", async () => {
|
test("state 一次性:重放授权回调被拒(OAuth CSRF 防护)", async () => {
|
||||||
const t = await makeTestEnv();
|
const t = await makeTestEnv();
|
||||||
const cookie = await loginCookie(t);
|
const cookie = await loginCookie(t);
|
||||||
@@ -240,7 +276,8 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
|
||||||
).json()) as { url: string };
|
).json()) as { url: string };
|
||||||
const scope = new URL(start.url).searchParams.get("scope") ?? "";
|
const scope = new URL(start.url).searchParams.get("scope") ?? "";
|
||||||
expect(scope).toContain("account.read");
|
// 默认只请求接入必需的三项(账号名权限可选,缺失时从 zone 反推)
|
||||||
|
expect(scope).toBe("zone.read workers-scripts.write email-routing-rule.write");
|
||||||
expect(scope).not.toContain(":");
|
expect(scope).not.toContain(":");
|
||||||
|
|
||||||
// settings 里自定义 scope 后生效
|
// settings 里自定义 scope 后生效
|
||||||
@@ -293,6 +330,7 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
hint: string | null;
|
hint: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
used: { redirect_uri: string };
|
used: { redirect_uri: string };
|
||||||
|
scopes: { allowed: string[]; denied: string[]; probed: boolean; note: string | null };
|
||||||
};
|
};
|
||||||
let res = (await (
|
let res = (await (
|
||||||
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
||||||
@@ -331,6 +369,52 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
expect(res.error).toBe("invalid_request");
|
expect(res.error).toBe("invalid_request");
|
||||||
expect(res.hint).toContain("Redirect URL");
|
expect(res.hint).toContain("Redirect URL");
|
||||||
|
|
||||||
|
// 默认 scope(三项)全被授权 => 直接通过
|
||||||
|
installCloudflareMock({ scopesAllowed: ["zone.read", "workers-scripts.write", "email-routing-rule.write"] });
|
||||||
|
res = (await (
|
||||||
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
||||||
|
).json()) as PreflightBody;
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
|
// 主动请求可选的 account-settings.read 而 App 没勾 => 逐轮剔除被点名的,收敛出 allowed/denied
|
||||||
|
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
cf_oauth: {
|
||||||
|
client_id: "preflight-client",
|
||||||
|
scope: "account-settings.read zone.read workers-scripts.write email-routing-rule.write",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
res = (await (
|
||||||
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
||||||
|
).json()) as PreflightBody;
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error).toBe("invalid_scope");
|
||||||
|
expect(res.scopes.probed).toBe(true);
|
||||||
|
// 候选集里 App 没勾的(所需的 account-settings.read + 4 个备选)都会被点名剔除
|
||||||
|
expect(res.scopes.denied).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"account-settings.read",
|
||||||
|
"email-routing-account-rule.read",
|
||||||
|
"email-routing-address.write",
|
||||||
|
"email-routing-suppression.write",
|
||||||
|
"zone-settings.read",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(res.scopes.allowed).toEqual(
|
||||||
|
expect.arrayContaining(["zone.read", "workers-scripts.write", "email-routing-rule.write"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// App 什么都没勾时:全部候选都算待勾选
|
||||||
|
installCloudflareMock({ scopesAllowed: [] });
|
||||||
|
res = (await (
|
||||||
|
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
|
||||||
|
).json()) as PreflightBody;
|
||||||
|
expect(res.scopes.allowed).toEqual([]);
|
||||||
|
expect(res.scopes.denied.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// 跳去登录/同意页 => 配置可用
|
// 跳去登录/同意页 => 配置可用
|
||||||
installCloudflareMock({ authorizeRedirect: "https://dash.cloudflare.com/login" });
|
installCloudflareMock({ authorizeRedirect: "https://dash.cloudflare.com/login" });
|
||||||
res = (await (
|
res = (await (
|
||||||
|
|||||||
Reference in New Issue
Block a user