feat(oauth): 账号名改从 zone.account 取,去掉 account-settings.read 依赖
实测确认 GET /zones 每个 zone 自带 account {id,name},该接口只需 zone.read,
因此账号 id 与账号名都不必再调 GET /accounts(account-settings.read)。
顺带修掉 account_id 旧字段在当前响应里已为 null 导致的降级路径取不到账号 id。
- RealCFApi.discover 只列 /zones,移除 listAccounts 与 accountSource
- scope 候选集去掉 account-settings.read;设置页移除「含账号名权限」按钮
- 设置页 Scope 说明与 docs/02、README 同步(补 User Details Read 的区别说明)
- 85/85 测试通过(新增 zone 带 account / 不带 account 两种断言)
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**(`zone.read` / `workers-scripts.write` / `email-routing-rule.write` / `account-settings.read`):**点号分隔**,冒号写法(`account:read`)是 wrangler 的第一方简写,第三方 client 会被拒;缺 `account-settings.read` 也能接入(自动从域名反推账号)
|
- **Scopes**(`zone.read` / `workers-scripts.write` / `email-routing-rule.write`):**点号分隔**,冒号写法(`account:read`)是 wrangler 的第一方简写,第三方 client 会被拒;这三项就够,账号 id 与账号名随 `GET /zones` 的每个 zone 一起返回,不需要账号级权限
|
||||||
|
|
||||||
配好后到设置页点「检测授权配置」确认,再点账号页「添加账号」。Client ID / Secret 有两个配置位置,任一即可:
|
配好后到设置页点「检测授权配置」确认,再点账号页「添加账号」。Client ID / Secret 有两个配置位置,任一即可:
|
||||||
- **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署;
|
- **推荐**:部署后登录 Console → 设置 →「Cloudflare 接入」填写,Secret 加密入库,改完立即生效,以后换 client 也不用重新部署;
|
||||||
|
|||||||
@@ -337,10 +337,6 @@ export interface OAuthPreflightResult {
|
|||||||
/** 本应用默认需要的 scope(点号分隔)——与后端 CF_OAUTH_SCOPES_DEFAULT 保持一致 */
|
/** 本应用默认需要的 scope(点号分隔)——与后端 CF_OAUTH_SCOPES_DEFAULT 保持一致 */
|
||||||
export const CF_OAUTH_SCOPES_DEFAULT = "zone.read workers-scripts.write email-routing-rule.write";
|
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");
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import {
|
|||||||
oauthPreflight,
|
oauthPreflight,
|
||||||
putSettings,
|
putSettings,
|
||||||
CF_OAUTH_SCOPES_DEFAULT,
|
CF_OAUTH_SCOPES_DEFAULT,
|
||||||
CF_OAUTH_SCOPES_WITH_ACCOUNT_NAME,
|
|
||||||
type OAuthPreflightResult,
|
type OAuthPreflightResult,
|
||||||
type SettingsInfo,
|
type SettingsInfo,
|
||||||
} from "../api.ts";
|
} from "../api.ts";
|
||||||
@@ -391,8 +390,7 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
|
|||||||
<>
|
<>
|
||||||
Cloudflare 的 OAuth scope 是<b>点号分隔</b>(zone.read),冒号写法(zone:read)是 wrangler 的
|
Cloudflare 的 OAuth scope 是<b>点号分隔</b>(zone.read),冒号写法(zone:read)是 wrangler 的
|
||||||
第一方简写,第三方客户端会被判 invalid_scope。必须与 OAuth App 里勾选的权限一致;检测按钮会
|
第一方简写,第三方客户端会被判 invalid_scope。必须与 OAuth App 里勾选的权限一致;检测按钮会
|
||||||
直接告诉你哪些已授权、哪些还要补勾。<b>account-settings.read 是可选的</b>:勾了才显示真实账号名,
|
直接告诉你哪些已授权、哪些还要补勾。三项就够:账号名随 zone 一起返回,不需要账号级权限。
|
||||||
不勾则用第一个域名代替,不影响接入。
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -449,15 +447,6 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
|
|||||||
>
|
>
|
||||||
填入最小 scope
|
填入最小 scope
|
||||||
</Button>
|
</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 && (
|
{check.scopes.allowed.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback";
|
|||||||
* 重要背景(实测结论):
|
* 重要背景(实测结论):
|
||||||
* - Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.<verb>`;
|
* - Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.<verb>`;
|
||||||
* `account:read` 这种冒号写法是 **wrangler 的第一方简写**,第三方客户端请求会被直接拒。
|
* `account:read` 这种冒号写法是 **wrangler 的第一方简写**,第三方客户端请求会被直接拒。
|
||||||
* - 「账号」不是 `account.read` 而是 `account-settings.read`(选择器里叫 Account Settings Read);
|
* - 账号名不需要额外的账号级权限:`GET /zones` 每个 zone 自带 `account: {id, name}`,
|
||||||
它只用来取账号名,**默认不请求** —— 缺它时 discover() 会从 zone 的 account_id 反推账户。
|
* 该接口只要求 Zone Read,所以本应用完全用不到 Account Settings Read。
|
||||||
* - Email Routing 在选择器里是 **四个独立权限**,其中改 catch-all 规则对应
|
* - Email Routing 在选择器里是 **四个独立权限**,其中改 catch-all 规则对应
|
||||||
* `email-routing-rule.write`(选择器里叫 Email Routing Rules Edit)。
|
* `email-routing-rule.write`(选择器里叫 Email Routing Rules Edit)。
|
||||||
* - 客户端注册的 scope 是「上限」而不是「默认值」:请求里没写的 scope 不会自动生效。
|
* - 客户端注册的 scope 是「上限」而不是「默认值」:请求里没写的 scope 不会自动生效。
|
||||||
@@ -48,7 +48,6 @@ export const CF_OAUTH_SCOPES_DEFAULT = [
|
|||||||
/** # 成员变量: scope 探测候选集(本应用所需 + 常见备选)
|
/** # 成员变量: scope 探测候选集(本应用所需 + 常见备选)
|
||||||
* 探测时逐个试:被 Cloudflare 点名拒绝的剔除,剩下的即为 App 已授权的集合。 **/
|
* 探测时逐个试:被 Cloudflare 点名拒绝的剔除,剩下的即为 App 已授权的集合。 **/
|
||||||
export const CF_OAUTH_SCOPE_CANDIDATES = [
|
export const CF_OAUTH_SCOPE_CANDIDATES = [
|
||||||
"account-settings.read",
|
|
||||||
"zone.read",
|
"zone.read",
|
||||||
"workers-scripts.write",
|
"workers-scripts.write",
|
||||||
"email-routing-rule.write",
|
"email-routing-rule.write",
|
||||||
@@ -351,6 +350,8 @@ export interface CFZone {
|
|||||||
name: string;
|
name: string;
|
||||||
account_id: string;
|
account_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** zone 所属账户(Cloudflare 在 GET /zones 的每个 zone 上直接附带,无需账号级读权限) **/
|
||||||
|
account?: { id: string; name?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 成员变量: 账号/域名发现结果 **/
|
/** # 成员变量: 账号/域名发现结果 **/
|
||||||
@@ -359,8 +360,6 @@ export interface CFDiscovery {
|
|||||||
account: CFAccount;
|
account: CFAccount;
|
||||||
/** 该账户(或 token 可见)下的域名 Zone */
|
/** 该账户(或 token 可见)下的域名 Zone */
|
||||||
zones: CFZone[];
|
zones: CFZone[];
|
||||||
/** 账号来源:accounts 接口,或因缺少账号级读权限而从 zone 的 account_id 反推 */
|
|
||||||
accountSource: "accounts" | "zones";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
|
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
|
||||||
@@ -437,16 +436,11 @@ export class RealCFApi implements CFApi {
|
|||||||
return data.result;
|
return data.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
listAccounts(token: string) {
|
async listZones(token: string) {
|
||||||
return this.api<CFAccount[]>(token, "/accounts");
|
// 分页拉全 Zone(每页 50,最多 20 页):token 可见的全部 Zone
|
||||||
}
|
|
||||||
|
|
||||||
async listZones(token: string, accountId?: string) {
|
|
||||||
// 分页拉全 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 query = new URLSearchParams({ per_page: "50", page: String(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()}`);
|
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;
|
||||||
@@ -455,24 +449,18 @@ export class RealCFApi implements CFApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 账号 + 域名发现。
|
/** 账号 + 域名发现。
|
||||||
* 优先用 /accounts 拿账户名(需要账号级读权限);
|
* 账号完全由 zone 推导:`GET /zones` 的每个 zone 自带 `account: {id, name}`,
|
||||||
* 没有该权限时退而求其次:直接列 token 可见的 Zone,用 zone 自带的 account_id 反推账户,
|
* 所以只要 zone.read 就能同时拿到域名和真实账号名,不需要任何账号级读权限
|
||||||
* 展示名用第一个域名代替 —— 这样只要授权了 zone.read 就能完成接入。 */
|
* (也就无需 Account Settings Read)。zone 上没带 account 时用第一个域名兜底展示。 */
|
||||||
async discover(token: string): Promise<CFDiscovery> {
|
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);
|
const zones = await this.listZones(token);
|
||||||
if (zones.length === 0) throw new Error("no cloudflare zone accessible with this token");
|
if (zones.length === 0) throw new Error("no cloudflare zone accessible with this token");
|
||||||
const accountId = zones[0].account_id;
|
const first = zones[0];
|
||||||
const more = zones.length > 1 ? ` 等 ${zones.length} 个域名` : "";
|
const fallback = `${first.name}${zones.length > 1 ? ` 等 ${zones.length} 个域名` : ""}`;
|
||||||
return { account: { id: accountId, name: `${zones[0].name}${more}` }, zones, accountSource: "zones" };
|
return {
|
||||||
|
account: { id: first.account?.id ?? first.account_id, name: first.account?.name || fallback },
|
||||||
|
zones,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async putEmailWorker(opts: {
|
async putEmailWorker(opts: {
|
||||||
|
|||||||
@@ -67,12 +67,11 @@ Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.
|
|||||||
是 **wrangler 的第一方简写**,第三方客户端请求会被一律拒绝(它只对第一方 client 生效)。权威列表是
|
是 **wrangler 的第一方简写**,第三方客户端请求会被一律拒绝(它只对第一方 client 生效)。权威列表是
|
||||||
`GET /client/v4/oauth/scopes`,选择器里是它的可勾选项。
|
`GET /client/v4/oauth/scopes`,选择器里是它的可勾选项。
|
||||||
|
|
||||||
本应用默认请求这四个(已在 Cloudflare 侧逐个验证可被接受):
|
本应用默认只请求这三个(已在 Cloudflare 侧逐个验证可被接受):
|
||||||
|
|
||||||
| scope id | 选择器里的名称 | 用途 |
|
| scope id | 选择器里的名称 | 用途 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `account-settings.read` | Account Settings Read | 列出账号(拿账号名) |
|
| `zone.read` | Zone Read | 发现该账号下的域名,并顺带拿到账号 id 与账号名 |
|
||||||
| `zone.read` | Zone Read | 发现该账号下的域名 |
|
|
||||||
| `workers-scripts.write` | Workers Scripts Edit | 上传账号级 Email Worker 脚本 |
|
| `workers-scripts.write` | Workers Scripts Edit | 上传账号级 Email Worker 脚本 |
|
||||||
| `email-routing-rule.write` | Email Routing Rules Edit | 开启 Email Routing + 配置 catch-all 规则 |
|
| `email-routing-rule.write` | Email Routing Rules Edit | 开启 Email Routing + 配置 catch-all 规则 |
|
||||||
|
|
||||||
@@ -84,11 +83,23 @@ Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.
|
|||||||
- **客户端注册的 scope 是上限,不是默认值**。App 里没勾的权限,即使账号本身有,授权时也不会生效;
|
- **客户端注册的 scope 是上限,不是默认值**。App 里没勾的权限,即使账号本身有,授权时也不会生效;
|
||||||
反过来请求里不写某个 scope,即使 App 勾了也不会被授予。
|
反过来请求里不写某个 scope,即使 App 勾了也不会被授予。
|
||||||
|
|
||||||
### 缺少账号级读权限时的降级(2026-09-30 实测后加入)
|
### 账号级读权限:不需要(2026-09-30 实测)
|
||||||
|
|
||||||
若 App 没有勾 `account-settings.read`,`GET /accounts` 会 403。Console 会自动降级:直接列 token 可见的
|
`GET /zones` 的每个 zone 自带 `account: {id, name}`,而该接口的 Accepted Permission 只有 Zone Read。
|
||||||
`/zones`,用 zone 自带的 `account_id` 反推目标账户,账号展示名用第一个域名代替(如 `example.com 等 3 个域名`)。
|
所以账号 id 和账号名都能从 zone 上直接读到:
|
||||||
因此**只勾 `zone.read` + `workers-scripts.write` + `email-routing-rule.write` 也能完成接入**,只是账号名显示为域名。
|
|
||||||
|
- **不需要 `account-settings.read`** —— 它只覆盖 `GET /accounts`(列账号),本应用不再调这个接口;
|
||||||
|
- **不需要 `user-details.read`** —— 那是读「授权的那个人」(`/user`),跟 Cloudflare 账号无关。
|
||||||
|
|
||||||
|
实测该账号的 `GET /zones` 响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "...", "name": "adx9.com",
|
||||||
|
"account": { "id": "bf8cd983e6dfd171c1881b00b512ed5a", "name": "Phptits@msn.com's Account" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 `account_id` 这个旧字段在当前响应里已经是 `null`,读账号必须走 `account.id`。zone 上没有 `account`
|
||||||
|
时(非标响应)才回退:用 `account_id` + 第一个域名展示(如 `example.com 等 3 个域名`)。
|
||||||
|
|
||||||
## 授权配置自检(不用开浏览器)
|
## 授权配置自检(不用开浏览器)
|
||||||
|
|
||||||
@@ -109,8 +120,8 @@ Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.
|
|||||||
`preflight` 在收到 `invalid_scope` 时会自动跑完这个探测,并在结果里返回:
|
`preflight` 在收到 `invalid_scope` 时会自动跑完这个探测,并在结果里返回:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"scopes": { "allowed": ["zone.read", "workers-scripts.write"],
|
"scopes": { "allowed": ["zone.read", "workers-scripts.write", "email-routing-rule.write"],
|
||||||
"denied": ["account-settings.read"], "probed": true, "note": null }
|
"denied": ["zone-settings.read"], "probed": true, "note": null }
|
||||||
```
|
```
|
||||||
|
|
||||||
设置页把两列分别渲染成绿色/橙色标签,「待勾选」列就是需要在 OAuth App 选择器里补上的权限。
|
设置页把两列分别渲染成绿色/橙色标签,「待勾选」列就是需要在 OAuth App 选择器里补上的权限。
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wmailhub",
|
"name": "wmailhub",
|
||||||
"version": "2.3.2",
|
"version": "2.3.3",
|
||||||
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+7
-20
@@ -21,10 +21,8 @@ export interface CfCall {
|
|||||||
|
|
||||||
/** # 成员变量: 桩的返回数据配置 **/
|
/** # 成员变量: 桩的返回数据配置 **/
|
||||||
export interface CfMockOptions {
|
export interface CfMockOptions {
|
||||||
/** /accounts 返回的账户列表 **/
|
/** /zones 返回的域名列表;每个 zone 自带 account(与真实 Cloudflare 响应一致) **/
|
||||||
accounts?: { id: string; name: string }[];
|
zones?: { id: string; name: string; account_id: string; status: string; account?: { id: string; name: string } }[];
|
||||||
/** /zones 返回的域名列表 **/
|
|
||||||
zones?: { id: string; name: string; account_id: string; status: string }[];
|
|
||||||
/** OAuth token 端点签发的 access token **/
|
/** OAuth token 端点签发的 access token **/
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
/** token 端点 HTTP 状态(测失败分支用) **/
|
/** token 端点 HTTP 状态(测失败分支用) **/
|
||||||
@@ -35,8 +33,6 @@ export interface CfMockOptions {
|
|||||||
scopesAllowed?: string[];
|
scopesAllowed?: string[];
|
||||||
/** 授权端点固定错误(走 Cloudflare 自己的 /oauth/error 页,不弹回回调地址) **/
|
/** 授权端点固定错误(走 Cloudflare 自己的 /oauth/error 页,不弹回回调地址) **/
|
||||||
authorizeError?: string;
|
authorizeError?: string;
|
||||||
/** /accounts 返回 403(模拟只授权了 zone 读、没有账号级读权限) */
|
|
||||||
failAccounts?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 成员变量: Cloudflare API 桩句柄 **/
|
/** # 成员变量: Cloudflare API 桩句柄 **/
|
||||||
@@ -58,13 +54,14 @@ const API_BASE = "https://api.cloudflare.com/client/v4";
|
|||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* 安装 Cloudflare API 桩:接管 globalThis.fetch,返回记录与恢复句柄
|
* 安装 Cloudflare API 桩:接管 globalThis.fetch,返回记录与恢复句柄
|
||||||
* 参数: opts.accounts/zones 决定发现到的资源,tokenStatus 可模拟 token 兑换失败
|
* 参数: opts.zones 决定发现到的资源,tokenStatus 可模拟 token 兑换失败
|
||||||
*/
|
*/
|
||||||
export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
||||||
const accounts = opts.accounts ?? [{ id: "cf-acct-real-1", name: "Real Cloudflare Account" }];
|
const account = { id: "cf-acct-real-1", name: "Real Cloudflare Account" };
|
||||||
|
// zone 自带 account(真实 Cloudflare 就在 zone 上附带),账号名因此无需账号级读权限
|
||||||
const zones = opts.zones ?? [
|
const zones = opts.zones ?? [
|
||||||
{ id: "zone-example", name: "example.com", account_id: accounts[0]?.id ?? "", status: "active" },
|
{ id: "zone-example", name: "example.com", account_id: account.id, status: "active", account },
|
||||||
{ id: "zone-demo", name: "demo.com", account_id: accounts[0]?.id ?? "", status: "active" },
|
{ id: "zone-demo", name: "demo.com", account_id: account.id, status: "active", account },
|
||||||
];
|
];
|
||||||
const accessToken = opts.accessToken ?? "cf-oauth-access-token-xyz";
|
const accessToken = opts.accessToken ?? "cf-oauth-access-token-xyz";
|
||||||
const calls: CfCall[] = [];
|
const calls: CfCall[] = [];
|
||||||
@@ -134,16 +131,6 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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 });
|
||||||
|
|||||||
+37
-14
@@ -74,10 +74,9 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
expect(credRow?.secret_enc).toBeTruthy();
|
expect(credRow?.secret_enc).toBeTruthy();
|
||||||
expect(credRow!.secret_last4.length).toBe(4);
|
expect(credRow!.secret_last4.length).toBe(4);
|
||||||
|
|
||||||
// 真实发出的 Cloudflare 调用:换 token -> 账户发现 -> 域名发现 -> 上传 Email Worker 脚本
|
// 真实发出的 Cloudflare 调用:换 token -> 域名发现(账号名随 zone 返回)-> 上传 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", "/zones?")).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,16 +120,16 @@ 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 () => {
|
test("账号名从 zone 自带的 account 取:不需要任何账号级读权限", async () => {
|
||||||
const t = await makeTestEnv();
|
const t = await makeTestEnv();
|
||||||
const cookie = await loginCookie(t);
|
const cookie = await loginCookie(t);
|
||||||
// /accounts 返回 403(没有 account 读取权限),zones 仍可用且带 account_id
|
// 真实 Cloudflare 的 GET /zones 每个 zone 都自带 account {id, name},只要 zone.read
|
||||||
|
const account = { id: "cf-acct-from-zone", name: "Phptits Account" };
|
||||||
const cf = installCloudflareMock({
|
const cf = installCloudflareMock({
|
||||||
zones: [
|
zones: [
|
||||||
{ id: "zone-a", name: "alpha.com", account_id: "cf-acct-from-zone", status: "active" },
|
{ id: "zone-a", name: "alpha.com", account_id: "", status: "active", account },
|
||||||
{ id: "zone-b", name: "beta.com", account_id: "cf-acct-from-zone", status: "active" },
|
{ id: "zone-b", name: "beta.com", account_id: "", status: "active", account },
|
||||||
],
|
],
|
||||||
failAccounts: true,
|
|
||||||
});
|
});
|
||||||
mock = cf;
|
mock = cf;
|
||||||
const { state } = (await (
|
const { state } = (await (
|
||||||
@@ -152,11 +151,36 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
};
|
};
|
||||||
expect(detail.account.status).toBe("active");
|
expect(detail.account.status).toBe("active");
|
||||||
expect(detail.account.cloudflare_account_id).toBe("cf-acct-from-zone");
|
expect(detail.account.cloudflare_account_id).toBe("cf-acct-from-zone");
|
||||||
// 展示名用第一个域名 + 域名数量
|
// 展示名就是真实账号名
|
||||||
expect(detail.account.name).toBe("alpha.com 等 2 个域名");
|
expect(detail.account.name).toBe("Phptits Account");
|
||||||
expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["alpha.com", "beta.com"]));
|
expect(detail.domains.map((d) => d.name)).toEqual(expect.arrayContaining(["alpha.com", "beta.com"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("zone 未附带 account 时:回退用 account_id + 第一个域名展示", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
const cf = installCloudflareMock({
|
||||||
|
zones: [
|
||||||
|
{ id: "zone-a", name: "alpha.com", account_id: "cf-acct-legacy", status: "active" },
|
||||||
|
{ id: "zone-b", name: "beta.com", account_id: "cf-acct-legacy", status: "active" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
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: { cloudflare_account_id: string | null; name: string } };
|
||||||
|
expect(detail.account.cloudflare_account_id).toBe("cf-acct-legacy");
|
||||||
|
expect(detail.account.name).toBe("alpha.com 等 2 个域名");
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
@@ -376,14 +400,14 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
).json()) as PreflightBody;
|
).json()) as PreflightBody;
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
// 主动请求可选的 account-settings.read 而 App 没勾 => 逐轮剔除被点名的,收敛出 allowed/denied
|
// 申请了 App 没勾的 scope => 逐轮剔除被点名的,收敛出 allowed/denied
|
||||||
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
cf_oauth: {
|
cf_oauth: {
|
||||||
client_id: "preflight-client",
|
client_id: "preflight-client",
|
||||||
scope: "account-settings.read zone.read workers-scripts.write email-routing-rule.write",
|
scope: "zone.read email-routing-address.write",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -393,10 +417,9 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
|||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
expect(res.error).toBe("invalid_scope");
|
expect(res.error).toBe("invalid_scope");
|
||||||
expect(res.scopes.probed).toBe(true);
|
expect(res.scopes.probed).toBe(true);
|
||||||
// 候选集里 App 没勾的(所需的 account-settings.read + 4 个备选)都会被点名剔除
|
// 候选集里 App 没勾的 4 个备选都会被点名剔除
|
||||||
expect(res.scopes.denied).toEqual(
|
expect(res.scopes.denied).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
"account-settings.read",
|
|
||||||
"email-routing-account-rule.read",
|
"email-routing-account-rule.read",
|
||||||
"email-routing-address.write",
|
"email-routing-address.write",
|
||||||
"email-routing-suppression.write",
|
"email-routing-suppression.write",
|
||||||
|
|||||||
Reference in New Issue
Block a user