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:
+41
-4
@@ -29,8 +29,14 @@ export interface CfMockOptions {
|
||||
accessToken?: string;
|
||||
/** token 端点 HTTP 状态(测失败分支用) **/
|
||||
tokenStatus?: number;
|
||||
/** 授权端点(dash.cloudflare.com/oauth2/auth)的 Location 跳转目标;不传则跳登录页 **/
|
||||
/** 授权端点(dash.cloudflare.com/oauth2/auth)的固定 Location 跳转目标;不传则按 scopesAllowed 判定 **/
|
||||
authorizeRedirect?: string;
|
||||
/** 授权端点允许的 scope 集合;其余一律 invalid_scope 并点名第一个被拒的(不传 = 全拒) **/
|
||||
scopesAllowed?: string[];
|
||||
/** 授权端点固定错误(走 Cloudflare 自己的 /oauth/error 页,不弹回回调地址) **/
|
||||
authorizeError?: string;
|
||||
/** /accounts 返回 403(模拟只授权了 zone 读、没有账号级读权限) */
|
||||
failAccounts?: boolean;
|
||||
}
|
||||
|
||||
/** # 成员变量: Cloudflare API 桩句柄 **/
|
||||
@@ -47,6 +53,7 @@ export interface CfMock {
|
||||
|
||||
const TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
|
||||
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";
|
||||
|
||||
/** # 函数
|
||||
@@ -93,8 +100,29 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
||||
});
|
||||
|
||||
if (url.startsWith(CF_AUTHORIZE_URL)) {
|
||||
const target = opts.authorizeRedirect ?? "https://dash.cloudflare.com/login";
|
||||
return new Response(null, { status: 302, headers: { Location: target } });
|
||||
if (opts.authorizeError) {
|
||||
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 ((opts.tokenStatus ?? 200) !== 200) {
|
||||
@@ -106,7 +134,16 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
||||
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.includes("/email/routing/rules/catch_all")) return json({ id: "catch-all-rule" });
|
||||
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 脚本
|
||||
expect(cf.hit("POST", "/oauth2/token")).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);
|
||||
// token 兑换请求带真实 code 与 client 凭据
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const t = await makeTestEnv();
|
||||
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")
|
||||
).json()) as { url: string };
|
||||
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(":");
|
||||
|
||||
// settings 里自定义 scope 后生效
|
||||
@@ -293,6 +330,7 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
|
||||
hint: string | null;
|
||||
description?: string | null;
|
||||
used: { redirect_uri: string };
|
||||
scopes: { allowed: string[]; denied: string[]; probed: boolean; note: string | null };
|
||||
};
|
||||
let res = (await (
|
||||
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.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" });
|
||||
res = (await (
|
||||
|
||||
Reference in New Issue
Block a user