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
+28 -8
View File
@@ -3,6 +3,7 @@
* GET /api/oauth/cloudflare/start 生成 state,返回授权跳转 URL(自动创建账号)
* GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动接入
* GET /api/oauth/mode OAuth 是否已配置
* GET /api/oauth/cloudflare/preflight 授权配置自检(只读探测 Cloudflare 授权端点)
* GET /api/oauth/connections 已建立的 OAuth 连接
*
* 流程(Connect Cloudflare Account):Console -> Cloudflare OAuth -> 用户授权 ->
@@ -20,10 +21,12 @@ import { requireSession } from "../middleware.ts";
import { ids } from "../../../shared/src/index.ts";
import {
CF_OAUTH_AUTHORIZE_URL,
CF_OAUTH_SCOPES,
buildAuthorizeUrl,
getCFApi,
getOAuthClient,
getOAuthScopes,
oauthRedirectUri,
preflightOAuth,
} from "../services/cfapi.ts";
import { provisionAccount, createAccount } from "../services/provision.ts";
import { encryptConnectionToken } from "../db.ts";
@@ -54,9 +57,16 @@ export function oauthRoutes(): App {
mode: client ? "cloudflare" : "unconfigured",
authorize_url_base: client ? CF_OAUTH_AUTHORIZE_URL : null,
source: client?.source ?? "none",
scope: await getOAuthScopes(c.env),
});
});
/** 授权配置自检:不打开浏览器也能确认 OAuth App 是否可用(见 cfapi.preflightOAuth) */
app.get("/cloudflare/preflight", async (c) => {
const result = await preflightOAuth(c.env, new URL(c.req.url).origin);
return c.json(result, result.ok ? 200 : 200);
});
/** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */
app.get("/cloudflare/start", async (c) => {
const client = await getOAuthClient(c.env);
@@ -74,13 +84,12 @@ export function oauthRoutes(): App {
.bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
.run();
const redirectUri = oauthRedirectUri(c.env, new URL(c.req.url).origin);
const url =
`${CF_OAUTH_AUTHORIZE_URL}?response_type=code` +
`&client_id=${encodeURIComponent(client.clientId)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
`&state=${encodeURIComponent(state)}`;
const url = buildAuthorizeUrl({
clientId: client.clientId,
redirectUri: oauthRedirectUri(c.env, new URL(c.req.url).origin),
scopes: await getOAuthScopes(c.env),
state,
});
return c.json({ url, state });
});
@@ -96,6 +105,17 @@ export function oauthRoutes(): App {
if (!verdict) {
return c.text("OAuth state invalid or expired (possible CSRF)", 400);
}
// 授权被 Cloudflare 拒绝(invalid_scope / access_denied ...):不兑换 token,
// 直接回控制台并带上错误信息,避免用户停在回调地址上看一个空壳页面。
const oauthError = url.searchParams.get("error");
if (oauthError) {
return c.redirect(
`/accounts?oauth_error=${encodeURIComponent(oauthError)}` +
`&oauth_error_description=${encodeURIComponent(url.searchParams.get("error_description") ?? "")}` +
`&account=${encodeURIComponent(verdict.accountId)}`,
302,
);
}
const cf = await getCFApi(c.env);
try {
const redirectUri = oauthRedirectUri(c.env, url.origin);
+9 -2
View File
@@ -24,6 +24,7 @@ import { getSetting, keyHash, setSetting } from "../db.ts";
import {
CF_OAUTH_SETTING_KEYS,
clearOAuthClient,
getOAuthScopes,
oauthClientMeta,
oauthRedirectUri,
saveOAuthClient,
@@ -79,7 +80,7 @@ export function settingsRoutes(): App {
username: user?.username ?? "",
webhook,
api_key: await apiKeyMeta(c.env),
cf_oauth: await oauthClientMeta(c.env),
cf_oauth: { ...(await oauthClientMeta(c.env)), scope: await getOAuthScopes(c.env) },
// 供前端展示:需要在 Cloudflare Dashboard 登记的回调地址
redirect_uri: oauthRedirectUri(c.env, new URL(c.req.url).origin),
});
@@ -89,7 +90,7 @@ export function settingsRoutes(): App {
app.put("/", async (c) => {
const body = await readJson<{
webhook?: { url?: string; headers?: unknown; key?: string };
cf_oauth?: { client_id?: string; secret?: string };
cf_oauth?: { client_id?: string; secret?: string; scope?: string };
}>(c.req.raw);
if (body.webhook) {
@@ -115,6 +116,12 @@ export function settingsRoutes(): App {
if (body.cf_oauth) {
const clientId = (body.cf_oauth.client_id ?? "").trim();
// scope 留空表示不改动;否则按空格分隔保存(去掉连续/首尾空白)
if (typeof body.cf_oauth.scope === "string") {
const scope = body.cf_oauth.scope.trim().replace(/\s+/g, " ");
if (scope) await setSetting(c.env, CF_OAUTH_SETTING_KEYS.scopes, scope);
else await c.env.DB.prepare("DELETE FROM settings WHERE key = ?").bind(CF_OAUTH_SETTING_KEYS.scopes).run();
}
// secret:undefined 保留现有;空白串(含 "")清除;非空设置新 secret
const rawSecret = typeof body.cf_oauth.secret === "string" ? body.cf_oauth.secret : undefined;
if (rawSecret === undefined) {