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:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -27,11 +27,24 @@ export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
||||
/** # 成员变量: 授权回调路径 **/
|
||||
export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback";
|
||||
|
||||
/** # 成员变量: OAuth 申请权限(docs/08 最小权限:账户与 Zone 读、Email Routing 写、Workers 脚本写)
|
||||
* 注意:Cloudflare OAuth 的 scope 是**点号分隔**的(冒号形式不被接受,见 Cloudflare
|
||||
* "Create OAuth Client" API 文档:Colon-delimited scopes are not accepted)。
|
||||
* 冒号写法会让授权端点直接返回 error=invalid_scope。
|
||||
* 这里只是默认值,实际请求的 scope 来自 settings.cf_oauth_scopes(可在设置页改)。 **/
|
||||
export const CF_OAUTH_SCOPES_DEFAULT = [
|
||||
"account.read",
|
||||
"zone.read",
|
||||
"email_routing.write",
|
||||
"workers_scripts.write",
|
||||
].join(" ");
|
||||
|
||||
/** # 成员变量: settings 表中保存 OAuth 客户端凭据的 key **/
|
||||
export const CF_OAUTH_SETTING_KEYS = {
|
||||
clientId: "cf_oauth_client_id",
|
||||
secretEnc: "cf_oauth_client_secret_enc",
|
||||
secretLast4: "cf_oauth_client_secret_last4",
|
||||
scopes: "cf_oauth_scopes",
|
||||
} as const;
|
||||
|
||||
/** # 成员变量: 已解析的 Cloudflare OAuth 客户端凭据 **/
|
||||
@@ -107,6 +120,128 @@ export async function clearOAuthClient(env: Env): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 解析实际请求的 scope 列表(settings 覆盖默认值;空白回退默认)
|
||||
*/
|
||||
export async function getOAuthScopes(env: Env): Promise<string> {
|
||||
const custom = (await getSetting(env, CF_OAUTH_SETTING_KEYS.scopes))?.trim();
|
||||
return custom || CF_OAUTH_SCOPES_DEFAULT;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 拼出 Cloudflare 授权页 URL
|
||||
*/
|
||||
export function buildAuthorizeUrl(opts: { clientId: string; redirectUri: string; scopes: string; state: string }): string {
|
||||
return (
|
||||
`${CF_OAUTH_AUTHORIZE_URL}?response_type=code` +
|
||||
`&client_id=${encodeURIComponent(opts.clientId)}` +
|
||||
`&redirect_uri=${encodeURIComponent(opts.redirectUri)}` +
|
||||
`&scope=${encodeURIComponent(opts.scopes)}` +
|
||||
`&state=${encodeURIComponent(opts.state)}`
|
||||
);
|
||||
}
|
||||
|
||||
/** # 成员变量: 授权配置自检结果 **/
|
||||
export interface OAuthPreflight {
|
||||
/** 授权端点是否接受当前 client_id / redirect_uri / scope 组合 */
|
||||
ok: boolean;
|
||||
/** Cloudflare 返回的 Location 指向(登录同意页 = 配置正确) */
|
||||
location: string;
|
||||
/** 错误码(invalid_scope / invalid_request / access_denied ...) */
|
||||
error: string | null;
|
||||
/** 错误描述原文 */
|
||||
description: string | null;
|
||||
/** 给用户的处置建议 */
|
||||
hint: string | null;
|
||||
/** 本次自检使用的参数 */
|
||||
used: { client_id: string; redirect_uri: string; scope: string };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 授权配置自检:用当前配置请求 Cloudflare 授权端点但**不跟随跳转**,
|
||||
* 据跳转目标判断 OAuth App 是否配置正确(纯只读探测,不会产生授权)。
|
||||
* 这样不用开浏览器就能验证 Client URI 校验、是否已 promote to public、scope 是否已授权。
|
||||
*/
|
||||
export async function preflightOAuth(env: Env, requestOrigin: string): Promise<OAuthPreflight> {
|
||||
const client = await getOAuthClient(env);
|
||||
const redirectUri = oauthRedirectUri(env, requestOrigin);
|
||||
const scope = await getOAuthScopes(env);
|
||||
const used = { client_id: client?.clientId ?? "", redirect_uri: redirectUri, scope };
|
||||
if (!client) {
|
||||
return {
|
||||
ok: false,
|
||||
location: "",
|
||||
error: "not_configured",
|
||||
description: "尚未配置 Cloudflare OAuth 客户端",
|
||||
hint: "先在下方填入 Client ID 与 Client Secret 并保存",
|
||||
used,
|
||||
};
|
||||
}
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(
|
||||
buildAuthorizeUrl({ clientId: client.clientId, redirectUri, scopes: scope, state: "wmh_preflight" }),
|
||||
{ redirect: "manual" },
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
location: "",
|
||||
error: "network",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
hint: "Console 无法访问 dash.cloudflare.com,请检查出网",
|
||||
used,
|
||||
};
|
||||
}
|
||||
const location = resp.headers.get("location") ?? "";
|
||||
if (resp.status >= 300 && resp.status < 400 && location) {
|
||||
const target = new URL(location, redirectUri);
|
||||
const callbackBase = redirectUri.split("?")[0];
|
||||
// Cloudflare 拒绝授权时有两种落点:弹回我们登记的回调地址,或跳它自己的 /oauth/error 页
|
||||
const isCallbackBounce = location.startsWith(callbackBase);
|
||||
const isCfErrorPage = target.origin === "https://dash.cloudflare.com" && target.pathname.startsWith("/oauth/");
|
||||
if (isCallbackBounce || isCfErrorPage) {
|
||||
const error = target.searchParams.get("error");
|
||||
const description = target.searchParams.get("error_description");
|
||||
return { ok: false, location, error, description, hint: hintForOAuthError(error), used };
|
||||
}
|
||||
// 跳到登录/同意页 = client_id、redirect_uri、scope 全部被接受
|
||||
return { ok: true, location, error: null, description: null, hint: null, used };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
location,
|
||||
error: `http_${resp.status}`,
|
||||
description: `授权端点返回 HTTP ${resp.status}`,
|
||||
hint: "检查 Client ID 是否正确",
|
||||
used,
|
||||
};
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 把 Cloudflare 授权错误翻译成可执行的处置建议
|
||||
*/
|
||||
function hintForOAuthError(error: string | null): string | null {
|
||||
if (error === "invalid_scope") {
|
||||
return (
|
||||
"OAuth App 未授权本应用请求的 scope(或是冒号写法)。到 Cloudflare Dashboard → " +
|
||||
"My Profile → OAuth Applications 编辑该应用:Client URI 填 Console 域名(如 https://wmailhub.adx9.com)、" +
|
||||
"按提示加 TXT 记录完成域名校验、Promote to public,并在 scopes 中加入本应用请求的点号格式 scope。" +
|
||||
"也可在本页调整 scope 字段与 App 中登记的一致。"
|
||||
);
|
||||
}
|
||||
if (error === "invalid_request") {
|
||||
return (
|
||||
"请求被拒:最常见是回调地址(Redirect URL)与应用里登记的不一致——请把本页显示的回调地址原样填进 " +
|
||||
"Cloudflare OAuth App 的 Redirect URL(本应用不会用其它地址回调)。"
|
||||
);
|
||||
}
|
||||
if (error === "access_denied") {
|
||||
return "授权被拒绝:请在 Cloudflare 页面确认同意授权。";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 计算 OAuth 授权回调地址(优先 CF_OAUTH_REDIRECT_URI,其次 PUBLIC_BASE_URL,最后请求 origin)
|
||||
*/
|
||||
@@ -117,14 +252,6 @@ export function oauthRedirectUri(env: Env, requestOrigin: string): string {
|
||||
: `${base.replace(/\/$/, "")}${CF_OAUTH_CALLBACK_PATH}`;
|
||||
}
|
||||
|
||||
/** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/
|
||||
export const CF_OAUTH_SCOPES = [
|
||||
"account:read",
|
||||
"zone:read",
|
||||
"email_routing:edit",
|
||||
"workers_scripts:edit",
|
||||
].join(" ");
|
||||
|
||||
/** # 成员变量: 邮件发现的 Cloudflare 资源描述 **/
|
||||
export interface CFAccount {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user