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:
@@ -306,6 +306,8 @@ export interface SettingsInfo {
|
||||
secret_last4: string;
|
||||
/** settings = 后台设置;env = 部署变量兜底;none = 未配置 */
|
||||
source: "settings" | "env" | "none";
|
||||
/** 实际请求的 scope(点号分隔,空格分隔多个) */
|
||||
scope: string;
|
||||
};
|
||||
/** 需要在 Cloudflare Dashboard 登记的授权回调地址 */
|
||||
redirect_uri: string;
|
||||
@@ -317,9 +319,22 @@ export const getSettings = () => api<SettingsInfo>("/api/settings");
|
||||
* webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值 */
|
||||
export const putSettings = (body: {
|
||||
webhook?: { url: string; headers: Record<string, string>; key?: string };
|
||||
cf_oauth?: { client_id: string; secret?: string };
|
||||
cf_oauth?: { client_id: string; secret?: string; scope?: string };
|
||||
}) => api("/api/settings", "PUT", body);
|
||||
|
||||
/** # 成员变量: 授权配置自检结果 **/
|
||||
export interface OAuthPreflightResult {
|
||||
ok: boolean;
|
||||
location: string;
|
||||
error: string | null;
|
||||
description: string | null;
|
||||
hint: string | null;
|
||||
used: { client_id: string; redirect_uri: string; scope: string };
|
||||
}
|
||||
|
||||
/** 授权配置自检:探测 Cloudflare 授权端点是否接受当前配置(不产生授权) */
|
||||
export const oauthPreflight = () => api<OAuthPreflightResult>("/api/oauth/cloudflare/preflight");
|
||||
|
||||
/** 清除后台保存的 Cloudflare OAuth 客户端凭据(env 兜底不受影响) */
|
||||
export const deleteCfOAuth = () => api("/api/settings/cf-oauth", "DELETE");
|
||||
|
||||
|
||||
@@ -34,7 +34,9 @@ import {
|
||||
deleteApiKey,
|
||||
deleteCfOAuth,
|
||||
getSettings,
|
||||
oauthPreflight,
|
||||
putSettings,
|
||||
type OAuthPreflightResult,
|
||||
type SettingsInfo,
|
||||
} from "../api.ts";
|
||||
|
||||
@@ -267,6 +269,7 @@ function ApiKeyCard({
|
||||
interface CfOAuthFormValues {
|
||||
client_id: string;
|
||||
secret?: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
@@ -276,18 +279,35 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm<CfOAuthFormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [check, setCheck] = useState<OAuthPreflightResult | null>(null);
|
||||
const meta = info.cf_oauth;
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({ client_id: meta.client_id });
|
||||
}, [meta.client_id, form]);
|
||||
form.setFieldsValue({ client_id: meta.client_id, scope: meta.scope });
|
||||
}, [meta.client_id, meta.scope, form]);
|
||||
|
||||
/** 授权配置自检:不打开浏览器,直接问 Cloudflare 这套配置能不能用 */
|
||||
const runPreflight = async () => {
|
||||
setChecking(true);
|
||||
setCheck(null);
|
||||
try {
|
||||
setCheck(await oauthPreflight());
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "自检失败");
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 保存 OAuth 客户端凭据 */
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
await putSettings({ cf_oauth: { client_id: values.client_id.trim(), secret: values.secret } });
|
||||
await putSettings({
|
||||
cf_oauth: { client_id: values.client_id.trim(), secret: values.secret, scope: values.scope },
|
||||
});
|
||||
message.success("Cloudflare OAuth 客户端已保存");
|
||||
form.setFieldValue("secret", undefined);
|
||||
onSaved();
|
||||
@@ -361,7 +381,23 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
|
||||
>
|
||||
<Input.Password placeholder="留空保留现有 Secret" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="scope"
|
||||
label="申请的 Scope(点号分隔,空格分隔多个)"
|
||||
extra={
|
||||
<>
|
||||
Cloudflare 的 OAuth scope 是<b>点号分隔</b>(account.read),冒号写法(account:read)会被判为
|
||||
invalid_scope。必须与 Cloudflare OAuth App 中登记的 scope 完全一致;App 未 promote to public
|
||||
或未授权这些 scope 时,点「检测授权配置」会直接报出来。
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input placeholder="account.read zone.read email_routing.write workers_scripts.write" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button loading={checking} onClick={() => void runPreflight()}>
|
||||
检测授权配置
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存
|
||||
</Button>
|
||||
@@ -378,6 +414,27 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v
|
||||
)}
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
{check && (
|
||||
<Alert
|
||||
type={check.ok ? "success" : "error"}
|
||||
showIcon
|
||||
message={
|
||||
check.ok
|
||||
? "配置可用:Cloudflare 已接受 Client ID、回调地址与 scope,点「添加账号」即可发起授权"
|
||||
: `授权配置有问题:${check.error ?? "未知错误"}`
|
||||
}
|
||||
description={
|
||||
<>
|
||||
{check.description && <div style={{ marginBottom: 6 }}>{check.description}</div>}
|
||||
{check.hint && <div>{check.hint}</div>}
|
||||
<div className="muted" style={{ marginTop: 6 }}>
|
||||
回调地址:{check.used.redirect_uri} · scope:{check.used.scope || "(空)"}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,25 @@ export function AccountsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// 授权被 Cloudflare 拒绝时回调会带 ?oauth_error=...,把原因直接显示出来
|
||||
const oauthError = params.get("oauth_error");
|
||||
if (oauthError) {
|
||||
const detailText = params.get("oauth_error_description") ?? "";
|
||||
modal.info({
|
||||
title: `Cloudflare 授权未完成(${oauthError})`,
|
||||
content: (
|
||||
<div>
|
||||
<p style={{ marginBottom: 8 }}>{detailText || "Cloudflare 拒绝了本次授权请求。"}</p>
|
||||
<p className="muted" style={{ marginBottom: 0 }}>
|
||||
常见原因:OAuth App 未 promote to public、Client URI 域名未校验、
|
||||
或 App 中登记的 scope 与本应用请求的不一致(需点号分隔)。
|
||||
到「设置 → Cloudflare 接入」点「检测授权配置」可看到具体原因。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// OAuth 回调携带 ?provision=<id> 时自动打开详情
|
||||
const provisionId = params.get("provision");
|
||||
if (provisionId) {
|
||||
|
||||
Reference in New Issue
Block a user