fix: v2.3.1 Cloudflare OAuth scope 点号格式 + 授权失败可读提示 + 配置自检

- 根因:Cloudflare OAuth 的 scope 是点号分隔(account.read),冒号写法不被接受,
  授权端点直接返回 error=invalid_scope。默认 scope 改为
  account.read zone.read email_routing.write workers_scripts.write
- scope 可在设置页自定义(settings.cf_oauth_scopes),需与 App 登记值逐字一致
- 新增 GET /api/oauth/cloudflare/preflight + 设置页「检测授权配置」按钮:
  只读探测授权端点(不跟随跳转),区分 登录同意页 / 弹回回调 / Cloudflare /oauth/error,
  并把 invalid_scope、invalid_request 翻译成可执行的处置建议
- 回调端点处理 error 参数:不再拿空 code 去兑换 token,改为跳回账号页并弹窗说明原因
  (此前会落到 SPA 404 空壳页)
- 账号页读取 ?oauth_error= 弹窗展示 Cloudflare 的原始描述
- docs/02 补 Cloudflare OAuth App 配置清单(Redirect URL / Client URI 域名校验 /
  Promote to public / 点号 scope);83 个测试全通过
This commit is contained in:
bigcode
2026-09-30 16:08:27 +08:00
parent 3be6e483b4
commit 1f4d45f719
11 changed files with 426 additions and 25 deletions
+109
View File
@@ -231,6 +231,115 @@ describe("Cloudflare OAuth(真实授权,Connect Cloudflare Account)", () => {
expect(creds.results.filter((r) => r.status === "revoked").length).toBe(1);
});
test("scope 用点号分隔(冒号会被 Cloudflare 判 invalid_scope),且可在设置里改", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 默认 scope 是点号格式
const start = (await (
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");
expect(scope).not.toContain(":");
// settings 里自定义 scope 后生效
await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cf_oauth: { client_id: "test-client-id", scope: " account.read zone.read " } }),
});
const start2 = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
).json()) as { url: string };
expect(new URL(start2.url).searchParams.get("scope")).toBe("account.read zone.read");
});
test("授权失败(error=invalid_scope):回调不兑换 token,改跳控制台并带回原因", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
installCloudflareMock(); // 若发生 token 兑换会被记录,下面断言没有
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?error=invalid_scope&error_description=${encodeURIComponent(
"The OAuth 2.0 Client is not allowed to request scope 'account:read'.",
)}&state=${state}`,
),
t.env,
);
expect(resp.status).toBe(302);
const location = resp.headers.get("Location") ?? "";
expect(location).toContain("/accounts?oauth_error=invalid_scope");
expect(location).toContain("oauth_error_description=");
// 不落库任何连接
const conns = await t.env.DB.prepare("SELECT id FROM oauth_connections").all();
expect(conns.results.length).toBe(0);
});
test("授权配置自检:/cloudflare/preflight 汇总 Cloudflare 的判定并给出处置建议", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 未配置 -> not_configured
delete t.env.CF_OAUTH_CLIENT_ID;
delete t.env.CF_OAUTH_CLIENT_SECRET;
type PreflightBody = {
ok: boolean;
error: string | null;
hint: string | null;
description?: string | null;
used: { redirect_uri: string };
};
let res = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
).json()) as PreflightBody;
expect(res.ok).toBe(false);
expect(res.error).toBe("not_configured");
expect(res.hint).toBeTruthy();
expect(res.used.redirect_uri).toBe("http://localhost/api/oauth/cloudflare/callback");
// 通过设置写入凭据(env 已删),再自检
await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cf_oauth: { client_id: "preflight-client", secret: "preflight-secret" } }),
});
// Cloudflare 把请求弹回我们的回调地址 => invalid_scope,并给出可执行建议
const redirectUri = "http://localhost/api/oauth/cloudflare/callback";
installCloudflareMock({
authorizeRedirect: `${redirectUri}?error=invalid_scope&error_description=The+requested+scope+is+invalid&state=wmh_preflight`,
});
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.description).toContain("scope");
expect(res.hint).toContain("Promote to public");
// 错误也可能落在 Cloudflare 自己的 /oauth/error 页(不弹回回调地址)
installCloudflareMock({ authorizeRedirect: "https://dash.cloudflare.com/oauth/error?error=invalid_request&error_description=redirect_uri+does+not+match&state=wmh_preflight" });
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_request");
expect(res.hint).toContain("Redirect URL");
// 跳去登录/同意页 => 配置可用
installCloudflareMock({ authorizeRedirect: "https://dash.cloudflare.com/login" });
res = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/preflight")
).json()) as PreflightBody;
expect(res.ok).toBe(true);
expect(res.error).toBeNull();
});
test("sim 通道已彻底移除:无 sim 授权端点、无 cf_sim_resources 表", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);