- 根因: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 个测试全通过
173 lines
7.2 KiB
TypeScript
173 lines
7.2 KiB
TypeScript
/**
|
|
* Cloudflare OAuth 路由(docs/02、docs/11 §14):
|
|
* 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 -> 用户授权 ->
|
|
* Console 保存 Account + 域名同步 + 部署 Email Worker + 配置 Worker Secret;用户无需安装命令。
|
|
*
|
|
* 安全:state 一次性(用后即删)、5 分钟过期、绑定账号;token AES-GCM 加密入库,
|
|
* 不进前端与日志;Account ownership 以 token 实际可访问的 accounts 为准。
|
|
* 授权回调不校验会话:浏览器从 dash.cloudflare.com 跨站重定向回来时
|
|
* SameSite=Strict 的会话 cookie 不会携带,该端点由一次性 state 承担 CSRF 防护。
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import type { Env, AppVars } from "../env.ts";
|
|
import { requireSession } from "../middleware.ts";
|
|
import { ids } from "../../../shared/src/index.ts";
|
|
import {
|
|
CF_OAUTH_AUTHORIZE_URL,
|
|
buildAuthorizeUrl,
|
|
getCFApi,
|
|
getOAuthClient,
|
|
getOAuthScopes,
|
|
oauthRedirectUri,
|
|
preflightOAuth,
|
|
} from "../services/cfapi.ts";
|
|
import { provisionAccount, createAccount } from "../services/provision.ts";
|
|
import { encryptConnectionToken } from "../db.ts";
|
|
import { nowIso } from "../util.ts";
|
|
|
|
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
|
|
|
/** # 成员变量: OAuth state 有效期(秒) **/
|
|
const STATE_TTL_SECONDS = 300;
|
|
|
|
/** # 函数
|
|
* 装配 OAuth 路由
|
|
*/
|
|
export function oauthRoutes(): App {
|
|
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
|
|
app.use("*", async (c, next) => {
|
|
// 授权回调是浏览器从 Cloudflare 跨站重定向回来的请求,会话 cookie 不会带上,
|
|
// 因此该端点跳过会话校验,改由一次性 state 校验防护(docs/08)。
|
|
// 注意:子应用里 c.req.path 是挂载后的完整路径(/api/oauth/...),不能直接等值比较。
|
|
if (c.req.path.endsWith("/cloudflare/callback")) return next();
|
|
return requireSession(c, next);
|
|
});
|
|
|
|
/** 授权模式与入口信息(前端 Connect Cloudflare Account 向导用) */
|
|
app.get("/mode", async (c) => {
|
|
const client = await getOAuthClient(c.env);
|
|
return c.json({
|
|
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);
|
|
if (!client) {
|
|
return c.json({ error: "cloudflare oauth not configured (在设置 → Cloudflare 接入 中填写 Client ID / Secret)" }, 503);
|
|
}
|
|
const created = await createAccount(c.env, {
|
|
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
|
|
});
|
|
|
|
const state = "cfstate_" + ids.oauthState();
|
|
await c.env.DB.prepare(
|
|
"INSERT INTO oauth_states (state, account_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
|
)
|
|
.bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
|
|
.run();
|
|
|
|
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 });
|
|
});
|
|
|
|
/** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */
|
|
app.get("/cloudflare/callback", async (c) => {
|
|
if (!(await getOAuthClient(c.env))) {
|
|
return c.text("Cloudflare OAuth not configured", 503);
|
|
}
|
|
const url = new URL(c.req.url);
|
|
const code = url.searchParams.get("code") ?? "";
|
|
const state = url.searchParams.get("state") ?? "";
|
|
const verdict = await consumeState(c.env, state);
|
|
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);
|
|
const token = await cf.exchangeToken(code, redirectUri);
|
|
// 加密入库(不进前端/日志)
|
|
await saveConnection(c.env, token.accessToken, token.expiresIn);
|
|
const result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken });
|
|
const target = `/accounts?provision=${encodeURIComponent(verdict.accountId)}&ok=${result.ok ? 1 : 0}`;
|
|
return c.redirect(target, 302);
|
|
} catch (err) {
|
|
return c.text(`OAuth provisioning failed: ${err instanceof Error ? err.message : String(err)}`, 500);
|
|
}
|
|
});
|
|
|
|
/** 已有连接列表(不含 token) */
|
|
app.get("/connections", async (c) => {
|
|
const rows = await c.env.DB.prepare(
|
|
"SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC",
|
|
).all();
|
|
return c.json({ items: rows.results });
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
/** # 函数
|
|
* 消费一次性 state:存在且未过期则删除并返回 account_id,否则 null(CSRF 防护核心)
|
|
*/
|
|
async function consumeState(
|
|
env: Env,
|
|
state: string,
|
|
): Promise<{ accountId: string } | null> {
|
|
if (!state) return null;
|
|
const row = await env.DB.prepare("SELECT account_id, expires_at FROM oauth_states WHERE state = ?")
|
|
.bind(state)
|
|
.first<{ account_id: string; expires_at: string }>();
|
|
if (!row) return null;
|
|
await env.DB.prepare("DELETE FROM oauth_states WHERE state = ?").bind(state).run();
|
|
if (row.expires_at < nowIso()) return null;
|
|
return { accountId: row.account_id };
|
|
}
|
|
|
|
/** # 函数
|
|
* OAuth access token 加密入库
|
|
*/
|
|
async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise<void> {
|
|
const encrypted = await encryptConnectionToken(env, accessToken);
|
|
await env.DB.prepare(
|
|
`INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at)
|
|
VALUES (?, 'cloudflare', NULL, ?, NULL, ?, ?, ?)`,
|
|
)
|
|
.bind(ids.oauth(), encrypted, new Date(Date.now() + expiresIn * 1000).toISOString(), nowIso(), nowIso())
|
|
.run();
|
|
}
|