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:
@@ -29,6 +29,8 @@ export interface CfMockOptions {
|
||||
accessToken?: string;
|
||||
/** token 端点 HTTP 状态(测失败分支用) **/
|
||||
tokenStatus?: number;
|
||||
/** 授权端点(dash.cloudflare.com/oauth2/auth)的 Location 跳转目标;不传则跳登录页 **/
|
||||
authorizeRedirect?: string;
|
||||
}
|
||||
|
||||
/** # 成员变量: Cloudflare API 桩句柄 **/
|
||||
@@ -44,6 +46,7 @@ export interface CfMock {
|
||||
}
|
||||
|
||||
const TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
|
||||
const CF_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
|
||||
const API_BASE = "https://api.cloudflare.com/client/v4";
|
||||
|
||||
/** # 函数
|
||||
@@ -89,6 +92,10 @@ export function installCloudflareMock(opts: CfMockOptions = {}): CfMock {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
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 (url === TOKEN_URL) {
|
||||
if ((opts.tokenStatus ?? 200) !== 200) {
|
||||
return new Response(JSON.stringify({ error: "invalid_grant" }), { status: opts.tokenStatus });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user