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:
bigcode
2026-09-30 17:03:59 +08:00
parent 7a1625d575
commit 5b887908e2
8 changed files with 82 additions and 88 deletions
+37 -14
View File
@@ -74,10 +74,9 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
expect(credRow?.secret_enc).toBeTruthy();
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("GET", "/accounts")).toBe(true);
expect(cf.calls.some((c) => c.path.startsWith("/zones?") && c.path.includes("account.id="))).toBe(true);
expect(cf.hit("GET", "/zones?")).toBe(true);
expect(cf.hit("PUT", "/workers/scripts/")).toBe(true);
// token 兑换请求带真实 code 与 client 凭据
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");
});
test("缺少账号级读权限时:从 zone 的 account_id 反推账户,接入照样完成", async () => {
test("账号名从 zone 自带的 account 取:不需要任何账号级读权限", async () => {
const t = await makeTestEnv();
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({
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" },
{ id: "zone-a", name: "alpha.com", account_id: "", status: "active", account },
{ id: "zone-b", name: "beta.com", account_id: "", status: "active", account },
],
failAccounts: true,
});
mock = cf;
const { state } = (await (
@@ -152,11 +151,36 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
};
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.account.name).toBe("Phptits Account");
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 () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
@@ -376,14 +400,14 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
).json()) as PreflightBody;
expect(res.ok).toBe(true);
// 主动请求可选的 account-settings.read 而 App 没勾 => 逐轮剔除被点名的,收敛出 allowed/denied
// 申请了 App 没勾的 scope => 逐轮剔除被点名的,收敛出 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",
scope: "zone.read email-routing-address.write",
},
}),
});
@@ -393,10 +417,9 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
expect(res.ok).toBe(false);
expect(res.error).toBe("invalid_scope");
expect(res.scopes.probed).toBe(true);
// 候选集里 App 没勾的(所需的 account-settings.read + 4 个备选)都会被点名剔除
// 候选集里 App 没勾的 4 个备选都会被点名剔除
expect(res.scopes.denied).toEqual(
expect.arrayContaining([
"account-settings.read",
"email-routing-account-rule.read",
"email-routing-address.write",
"email-routing-suppression.write",