Files
wmailhub/console/src/services/cfapi.ts
T
bigcode 7a1625d575 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 即可「添加账号」
2026-09-30 16:26:43 +08:00

528 lines
21 KiB
TypeScript

/**
* Cloudflare API 客户端:真实调用 api.cloudflare.com / dash.cloudflare.com。
*
* OAuth access token 来自 Cloudflare OAuth 授权(docs/02),Console 用它完成
* 账号/域名发现、Email Worker 部署与 Email Routing 配置。
*
* OAuth 客户端凭据(client id / secret)有两个来源,settings 表优先、env 兜底:
* 1. settings 表 —— 在「设置 → Cloudflare 接入」里填写;secret 用 AES-GCM 加密存储
* (cf_oauth_client_secret_enc),只回 last4。保存后立即生效,无需重新部署。
* 2. env 变量 CF_OAUTH_CLIENT_ID / CF_OAUTH_CLIENT_SECRET —— 部署时注入的兜底。
* 两者都没有时不提供任何降级通道,接入接口直接返回 503,避免出现"看似接入成功"的假数据。
*/
import { renderEmailWorkerScript } from "../../../shared/src/index.ts";
import type { Env } from "../env.ts";
import { decryptSecret, encryptSecret, getSetting, setSetting } from "../db.ts";
/** # 成员变量: OAuth 授权端点 **/
export const CF_OAUTH_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
/** # 成员变量: OAuth token 端点 **/
export const CF_OAUTH_TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
/** # 成员变量: Cloudflare API 基址 **/
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
/** # 成员变量: 授权回调路径 **/
export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback";
/** # 成员变量: 本应用实际需要的 scope(点号分隔;与 Cloudflare OAuth App 里勾选的权限对应)
*
* 重要背景(实测结论):
* - Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `<group>.<verb>`;
* `account:read` 这种冒号写法是 **wrangler 的第一方简写**,第三方客户端请求会被直接拒。
* - 「账号」不是 `account.read` 而是 `account-settings.read`(选择器里叫 Account Settings Read);
它只用来取账号名,**默认不请求** —— 缺它时 discover() 会从 zone 的 account_id 反推账户。
* - Email Routing 在选择器里是 **四个独立权限**,其中改 catch-all 规则对应
* `email-routing-rule.write`(选择器里叫 Email Routing Rules Edit)。
* - 客户端注册的 scope 是「上限」而不是「默认值」:请求里没写的 scope 不会自动生效。
* 以上名称可用 /cloudflare/preflight 的 scope 探测功能现场核对(见 discoverAllowedScopes)。
**/
export const CF_OAUTH_SCOPES_DEFAULT = [
"zone.read",
"workers-scripts.write",
"email-routing-rule.write",
].join(" ");
/** # 成员变量: scope 探测候选集(本应用所需 + 常见备选)
* 探测时逐个试:被 Cloudflare 点名拒绝的剔除,剩下的即为 App 已授权的集合。 **/
export const CF_OAUTH_SCOPE_CANDIDATES = [
"account-settings.read",
"zone.read",
"workers-scripts.write",
"email-routing-rule.write",
"email-routing-account-rule.read",
"email-routing-address.write",
"email-routing-suppression.write",
"zone-settings.read",
];
/** # 成员变量: 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 客户端凭据 **/
export interface CFOAuthClient {
clientId: string;
clientSecret: string;
/** 凭据来源:settings(后台设置)优先,env(部署变量)兜底 */
source: "settings" | "env";
}
/** # 函数
* 解析当前生效的 Cloudflare OAuth 客户端凭据(settings 优先,env 兜底)
* 返回值: 未配置时 null
*/
export async function getOAuthClient(env: Env): Promise<CFOAuthClient | null> {
const [clientId, secretEnc] = await Promise.all([
getSetting(env, CF_OAUTH_SETTING_KEYS.clientId),
getSetting(env, CF_OAUTH_SETTING_KEYS.secretEnc),
]);
if (clientId && secretEnc) {
try {
const secret = await decryptSecret(env, secretEnc);
if (secret) return { clientId, clientSecret: secret, source: "settings" };
} catch {
// 密文损坏(主密钥变更等)时继续尝试 env 兜底
}
}
if (env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET) {
return { clientId: env.CF_OAUTH_CLIENT_ID, clientSecret: env.CF_OAUTH_CLIENT_SECRET, source: "env" };
}
return null;
}
/** # 函数
* Cloudflare OAuth 是否已配置(settings 或 env 任一来源可用)
*/
export async function isOAuthConfigured(env: Env): Promise<boolean> {
return (await getOAuthClient(env)) !== null;
}
/** # 函数
* OAuth 客户端凭据的展示用元信息(不含明文 secret)
*/
export async function oauthClientMeta(
env: Env,
): Promise<{ client_id: string; has_secret: boolean; secret_last4: string; source: "settings" | "env" | "none" }> {
const client = await getOAuthClient(env);
if (!client) return { client_id: "", has_secret: false, secret_last4: "", source: "none" };
const last4 = await getSetting(env, CF_OAUTH_SETTING_KEYS.secretLast4);
return {
client_id: client.clientId,
has_secret: true,
secret_last4: client.source === "settings" ? (last4 ?? "") : client.clientSecret.slice(-4),
source: client.source,
};
}
/** # 函数
* 保存 OAuth 客户端凭据到 settings(secret 加密存储,只留 last4 供展示)
*/
export async function saveOAuthClient(env: Env, clientId: string, secret: string): Promise<void> {
await setSetting(env, CF_OAUTH_SETTING_KEYS.clientId, clientId);
await setSetting(env, CF_OAUTH_SETTING_KEYS.secretEnc, await encryptSecret(env, secret));
await setSetting(env, CF_OAUTH_SETTING_KEYS.secretLast4, secret.slice(-4));
}
/** # 函数
* 清除 settings 中的 OAuth 客户端凭据(env 兜底不受影响)
*/
export async function clearOAuthClient(env: Env): Promise<void> {
for (const key of Object.values(CF_OAUTH_SETTING_KEYS)) {
await env.DB.prepare("DELETE FROM settings WHERE key = ?").bind(key).run();
}
}
/** # 函数
* 解析实际请求的 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 };
/** scope 探测结果:App 已授权 / 尚未授权 */
scopes: { allowed: string[]; denied: string[]; probed: boolean; note: string | null };
}
/** # 函数
* 授权配置自检:用当前配置请求 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 };
const noScopes = { allowed: [], denied: [], probed: false, note: null };
if (!client) {
return {
ok: false,
location: "",
error: "not_configured",
description: "尚未配置 Cloudflare OAuth 客户端",
hint: "先在下方填入 Client ID 与 Client Secret 并保存",
used,
scopes: noScopes,
};
}
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,
scopes: noScopes,
};
}
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");
// 被拒的是 scope 时顺便探测出「已授权 / 待勾选」,省得用户猜选择器里该勾哪几项
const scopes =
error === "invalid_scope"
? await discoverAllowedScopes(client.clientId, redirectUri)
: { allowed: [], denied: [], probed: false, note: null };
return { ok: false, location, error, description, hint: hintForOAuthError(error), used, scopes };
}
// 跳到登录/同意页 = client_id、redirect_uri、scope 全部被接受
return { ok: true, location, error: null, description: null, hint: null, used, scopes: noScopes };
}
return {
ok: false,
location,
error: `http_${resp.status}`,
description: `授权端点返回 HTTP ${resp.status}`,
hint: "检查 Client ID 是否正确",
used,
scopes: noScopes,
};
}
/** # 函数
* scope 探测:授权端点在**显示同意页之前**就会校验 scope,被拒时通过 error_description 点名是哪一个。
* 于是可以逐轮「请求剩余候选 → 剔除被点名的那个」,收敛出该 App 实际已授权的集合。
* 全程无需登录/同意,不产生任何授权;每轮一个未认证请求。
* 返回值: allowed=已授权、denied=尚未授权(需在选择器里补勾)
*/
async function probeScopes(
clientId: string,
redirectUri: string,
): Promise<{ allowed: string[]; denied: string[]; error: string | null }> {
const remaining = [...CF_OAUTH_SCOPE_CANDIDATES];
const denied: string[] = [];
for (let i = 0; i < CF_OAUTH_SCOPE_CANDIDATES.length + 1; i++) {
if (remaining.length === 0) return { allowed: [], denied, error: null };
let location = "";
try {
const resp = await fetch(
buildAuthorizeUrl({ clientId, redirectUri, scopes: remaining.join(" "), state: "wmh_preflight" }),
{ redirect: "manual" },
);
location = resp.headers.get("location") ?? "";
} catch {
return { allowed: remaining, denied, error: "network" };
}
if (!location) return { allowed: remaining, denied, error: "no_redirect" };
const target = new URL(location, redirectUri);
const error = target.searchParams.get("error");
if (error !== "invalid_scope") {
// 其它错误(回调地址不匹配等)与 scope 无关,停止并把剩余当作"未知"
return { allowed: remaining, denied, error };
}
const named = /scope '([^']+)'/.exec(target.searchParams.get("error_description") ?? "")?.[1];
const rejected = named && remaining.includes(named) ? named : remaining[0];
denied.push(rejected);
remaining.splice(remaining.indexOf(rejected), 1);
}
return { allowed: remaining, denied, error: null };
}
/** # 函数
* 对外包装的 scope 探测:返回可直接展示给用户的结果
*/
export async function discoverAllowedScopes(
clientId: string,
redirectUri: string,
): Promise<OAuthPreflight["scopes"]> {
const res = await probeScopes(clientId, redirectUri);
if (res.error) {
return {
allowed: res.allowed,
denied: res.denied,
probed: false,
note: `scope 探测中断(${res.error});请在 Cloudflare App 里逐项核对权限`,
};
}
return { allowed: res.allowed, denied: res.denied, probed: true, note: null };
}
/** # 函数
* 把 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,并在选择器里勾选下方「待勾选」列出的权限。客户端注册的 scope 是上限而非默认," +
"没勾的权限即使账号有也不会生效。"
);
}
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)
*/
export function oauthRedirectUri(env: Env, requestOrigin: string): string {
const base = env.CF_OAUTH_REDIRECT_URI ?? env.PUBLIC_BASE_URL ?? requestOrigin;
return base.endsWith(CF_OAUTH_CALLBACK_PATH)
? base
: `${base.replace(/\/$/, "")}${CF_OAUTH_CALLBACK_PATH}`;
}
/** # 成员变量: 邮件发现的 Cloudflare 资源描述 **/
export interface CFAccount {
id: string;
name: string;
}
export interface CFZone {
id: string;
name: string;
account_id: string;
status: string;
}
/** # 成员变量: 账号/域名发现结果 **/
export interface CFDiscovery {
/** 目标 Cloudflare 账户(id + 展示名) */
account: CFAccount;
/** 该账户(或 token 可见)下的域名 Zone */
zones: CFZone[];
/** 账号来源:accounts 接口,或因缺少账号级读权限而从 zone 的 account_id 反推 */
accountSource: "accounts" | "zones";
}
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
export interface CFApi {
/** 用 code 换 access token */
exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>;
/** 发现 token 可访问的账户 + 其下域名 */
discover(accessToken: string): Promise<CFDiscovery>;
/** 创建或更新账号级 Email Worker(幂等) */
putEmailWorker(opts: {
accessToken: string;
accountId: string;
scriptName: string;
/** Console 对外地址(Worker -> Console) **/
consoleUrl: string;
/** Worker 身份(与 WorkerCredential.worker_id 一致) **/
workerId: string;
/** Worker credential secret(HMAC 签名密钥) **/
workerSecret: string;
}): Promise<{ id: string }>;
/** 开启 Zone 的 Email Routing(幂等) */
enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>;
/** 配置 Zone catch-all 规则指向 Email Worker(幂等) */
setCatchAllRule(opts: {
accessToken: string;
zoneId: string;
workerName: string;
}): Promise<{ ok: boolean }>;
}
/** # 函数
* 构造 Cloudflare API 客户端(内部使用已解析的 OAuth 客户端凭据)
* 返回值: 未配置 OAuth 客户端时抛错(调用方应先判断 isOAuthConfigured)
*/
export async function getCFApi(env: Env): Promise<CFApi> {
const client = await getOAuthClient(env);
if (!client) throw new Error("cloudflare oauth not configured");
return new RealCFApi(client);
}
/** # 成员变量: Cloudflare API 实现 **/
export class RealCFApi implements CFApi {
/** # 成员变量: OAuth 客户端凭据(settings 或 env) **/
constructor(private client: CFOAuthClient) {}
async exchangeToken(code: string, redirectUri: string) {
const resp = await fetch(CF_OAUTH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: this.client.clientId,
client_secret: this.client.clientSecret,
redirect_uri: redirectUri,
}),
});
if (!resp.ok) throw new Error(`cloudflare token exchange failed: ${resp.status}`);
const data = (await resp.json()) as { access_token: string; expires_in?: number };
return { accessToken: data.access_token, expiresIn: data.expires_in ?? 3600 };
}
private async api<T>(token: string, path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(`${CF_API_BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
const data = (await resp.json()) as { success: boolean; errors?: { message: string }[]; result: T };
if (!data.success) throw new Error(`cloudflare api ${path}: ${data.errors?.[0]?.message ?? resp.status}`);
return data.result;
}
listAccounts(token: string) {
return this.api<CFAccount[]>(token, "/accounts");
}
async listZones(token: string, accountId?: string) {
// 分页拉全 Zone(每页 50,最多 20 页);不给 accountId 时返回 token 可见的全部 Zone
const out: CFZone[] = [];
for (let page = 1; page <= 20; page++) {
const query = new URLSearchParams({ per_page: "50", page: String(page) });
if (accountId) query.set("account.id", accountId);
const rows = await this.api<CFZone[]>(token, `/zones?${query.toString()}`);
out.push(...rows);
if (rows.length < 50) break;
}
return out;
}
/** 账号 + 域名发现。
* 优先用 /accounts 拿账户名(需要账号级读权限);
* 没有该权限时退而求其次:直接列 token 可见的 Zone,用 zone 自带的 account_id 反推账户,
* 展示名用第一个域名代替 —— 这样只要授权了 zone.read 就能完成接入。 */
async discover(token: string): Promise<CFDiscovery> {
try {
const accounts = await this.listAccounts(token);
if (accounts.length > 0) {
const account = accounts[0];
return { account, zones: await this.listZones(token, account.id), accountSource: "accounts" };
}
} catch {
// 缺少账号级读权限,走下面的反推路径
}
const zones = await this.listZones(token);
if (zones.length === 0) throw new Error("no cloudflare zone accessible with this token");
const accountId = zones[0].account_id;
const more = zones.length > 1 ? ` 等 ${zones.length} 个域名` : "";
return { account: { id: accountId, name: `${zones[0].name}${more}` }, zones, accountSource: "zones" };
}
async putEmailWorker(opts: {
accessToken: string;
accountId: string;
scriptName: string;
consoleUrl: string;
workerId: string;
workerSecret: string;
}) {
// Email Worker 即带 email handler 的 Worker 脚本;Console 地址与 Worker 凭据以明文常量打进脚本,
// 避免给 Worker 配置额外的 secrets 绑定(docs/11 §8:凭据本身经 HMAC 保护 Console API)。
const script = renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret);
const form = new FormData();
form.append(
"metadata",
new Blob([JSON.stringify({ main_module: "worker.js", bindings: [] })], { type: "application/json" }),
);
form.append("worker.js", new Blob([script], { type: "application/javascript+module" }));
const resp = await fetch(
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
{
method: "PUT",
headers: { Authorization: `Bearer ${opts.accessToken}` },
body: form,
},
);
if (!resp.ok) throw new Error(`upload email worker failed: ${resp.status}`);
return { id: opts.scriptName };
}
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) {
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing`, {
method: "POST",
body: JSON.stringify({ enabled: true, skip_wizard: true }),
});
return { enabled: true };
}
async setCatchAllRule(opts: { accessToken: string; zoneId: string; workerName: string }) {
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/rules/catch_all`, {
method: "PUT",
body: JSON.stringify({
matchers: [{ type: "all" }],
actions: [{ type: "worker", value: [opts.workerName] }],
enabled: true,
name: "wmailhub catch-all",
}),
});
return { ok: true };
}
}