# Cloudflare OAuth 与自动接入(docs/11 §14) 旧「Add Node」概念已删除,改为 **Connect Cloudflare Account**。 ## 流程 ```text Console | v Cloudflare OAuth | v 用户授权 Account | v Console +-- 保存 Account +-- Account / Zone 发现 +-- 配置 Domain(同步账号下全部域名,pending) +-- 创建 catch-all Mailbox +-- 部署账号级 Email Worker +-- 签发 WorkerCredential(secret 注入 Worker 脚本) ``` 用户不需要执行安装命令,也不手工创建 API Token。 ## 安全 - Cloudflare credential(OAuth access token)AES-GCM 加密存储,不进入前端和日志。 - OAuth state 一次性(用后即删)、5 分钟过期、绑定账号(CSRF 防护)。 - **授权回调不校验会话**:浏览器从 `dash.cloudflare.com` 跳回 Console 属于跨站顶级导航,`SameSite=Strict` 的会话 cookie 不会携带;该端点由一次性 state 承担 CSRF 防护,其余 `/api/oauth/*` 仍要求登录。 - Account ownership 以 token 实际可访问的 accounts 为准。 - 安装过程幂等,重复执行不产生重复资源。 - Worker credential 与 Cloudflare credential 分离;轮换 credential 后已配置域名自动重新部署。 ## 前置条件:OAuth 客户端凭据 授权所需的 Client ID / Secret 有两个来源,**settings 表优先、env 兜底**: | 来源 | 配置位置 | 说明 | | --- | --- | --- | | settings(优先) | Console「设置 → Cloudflare 接入」 | Secret 以 AES-GCM 密文存 `settings.cf_oauth_client_secret_enc`,只回显末 4 位;保存后立即生效,无需重新部署。清除走 `DELETE /api/settings/cf-oauth` | | env(兜底) | `CF_OAUTH_CLIENT_ID` / `CF_OAUTH_CLIENT_SECRET` | 部署时注入;settings 未配置或密文损坏时使用 | 两个来源都没有时,`/api/oauth/cloudflare/start` 与 `/api/oauth/cloudflare/callback` 一律返回 503 —— 系统不提供任何模拟/降级通道,不会出现"看起来接入成功"的假账号与假资源。 `GET /api/settings` 会返回 `cf_oauth.source`(settings / env / none)与 `redirect_uri`(需在 Cloudflare Dashboard 登记的回调地址),`GET /api/oauth/mode` 返回 `source` 供账号页判断。 ## Cloudflare OAuth App 配置清单(docs 依据:Cloudflare "Create OAuth Client" API) 在 新建应用后,**必须**完成下面几项,否则授权端点 会直接拒绝(此时 Console 收不到 code,只有 `error=invalid_scope` / `invalid_request`): | 字段 | 填什么 | 不对时的报错 | | --- | --- | --- | | Redirect URL(s) | `https://wmailhub.adx9.com/api/oauth/cloudflare/callback`(本地联调另加 `http://127.0.0.1:18787/api/oauth/cloudflare/callback`) | `invalid_request`: redirect_uri does not match | | **Client URI** | Console 站点首页,如 `https://wmailhub.adx9.com` | `promotion to public requires a verified client_uri host` | | 域名校验 | 按 Cloudflare 提示在 client_uri 域下加 TXT 记录(形如 `cloudflare_oauth_client_publisher=...`) | 同上,一直卡在 not verified | | Promote to public | 应用可见性从 private 提升为 public | 私有 App 不可被他人授权 | | Scopes | 与 Console「设置 → Cloudflare 接入」里的 scope 字段**逐字一致** | `invalid_scope`: Client is not allowed to request scope '...' | ### scope 词表与选择器的对应关系 Cloudflare 第三方 OAuth 客户端的 scope 是 **点号分隔** 的 `.`。`account:read` 这种冒号写法 是 **wrangler 的第一方简写**,第三方客户端请求会被一律拒绝(它只对第一方 client 生效)。权威列表是 `GET /client/v4/oauth/scopes`,选择器里是它的可勾选项。 本应用默认只请求这三个(已在 Cloudflare 侧逐个验证可被接受): | scope id | 选择器里的名称 | 用途 | | --- | --- | --- | | `zone.read` | Zone Read | 发现该账号下的域名,并顺带拿到账号 id 与账号名 | | `workers-scripts.write` | Workers Scripts Edit | 上传账号级 Email Worker 脚本 | | `email-routing-rule.write` | Email Routing Rules Edit | 配置 catch-all 规则指向 Worker | | `zone-settings.write` | Zone Settings Write | **开启 Email Routing**(见下) | **「开启 Email Routing」不归 Email Routing 权限管**(2026-09-30 实测):`PUT /zones/{id}/email/routing` 的 Accepted Permission 是 **Zone Settings Write**。所以 Email Routing 那一族权限全勾上也不够, 漏掉 `zone-settings.write` 会一直等到点「设置 Email Workers」才报出来。 顺带两个接口层面的坑: - 开启 Email Routing 用 **`PUT /zones/{id}/email/routing`**(Apply Email Routing settings)。 `POST` 到同一路径不是有效路由,返回 `10405 Method not allowed for this authentication scheme`; `POST .../email/routing/enable` 能用但官方已标 Deprecated。 - catch-all 规则的请求体里,指向 Worker 的 action 是 `{ "type": "worker", "value": ["<脚本名>"] }`, matchers 固定 `[{"type":"all"}]`。 两个容易踩的点: - **Email Routing 在选择器里是四个独立权限**(`Email Routing Account Rules` / `Addresses` / `Rules` / `Suppressions`),不是笼统的"email routing 写"。改 catch-all 规则对应上面表里的 `email-routing-rule.write`。 - **客户端注册的 scope 是上限,不是默认值**。App 里没勾的权限,即使账号本身有,授权时也不会生效; 反过来请求里不写某个 scope,即使 App 勾了也不会被授予。 ### 账号级读权限:不需要(2026-09-30 实测) `GET /zones` 的每个 zone 自带 `account: {id, name}`,而该接口的 Accepted Permission 只有 Zone Read。 所以账号 id 和账号名都能从 zone 上直接读到: - **不需要 `account-settings.read`** —— 它只覆盖 `GET /accounts`(列账号),本应用不再调这个接口; - **不需要 `user-details.read`** —— 那是读「授权的那个人」(`/user`),跟 Cloudflare 账号无关。 实测该账号的 `GET /zones` 响应: ```json { "id": "...", "name": "adx9.com", "account": { "id": "bf8cd983e6dfd171c1881b00b512ed5a", "name": "Phptits@msn.com's Account" } } ``` 注意 `account_id` 这个旧字段在当前响应里已经是 `null`,读账号必须走 `account.id`。zone 上没有 `account` 时(非标响应)才回退:用 `account_id` + 第一个域名展示(如 `example.com 等 3 个域名`)。 ## 授权配置自检(不用开浏览器) `GET /api/oauth/cloudflare/preflight` 用当前配置请求一次 Cloudflare 授权端点但**不跟随跳转**,据跳转目标判定: - 跳到登录/同意页 → `ok: true`,配置可用; - 弹回我们的回调地址,或跳 `dash.cloudflare.com/oauth/error` → `ok: false` 并带回 `error` / `error_description` 与一条处置建议。 设置页「Cloudflare 接入」tab 的「检测授权配置」按钮就是调它。纯只读探测,不会产生任何授权。 ### scope 探测:直接算出"哪些已授权、哪些还要勾" 授权端点在**显示同意页之前**就校验 scope,拒绝时通过 `error_description` **点名**是哪一个 (`... not allowed to request scope 'xxx'`)。于是可以逐轮「请求剩余候选 → 剔除被点名的那一个」, 收敛出该 App 实际持有的 scope 集合——每轮只是一个未认证请求,无需登录、无需同意、不产生任何授权。 `preflight` 在收到 `invalid_scope` 时会自动跑完这个探测,并在结果里返回: ```json "scopes": { "allowed": ["zone.read", "workers-scripts.write", "email-routing-rule.write"], "denied": ["zone-settings.read"], "probed": true, "note": null } ``` 设置页把两列分别渲染成绿色/橙色标签,「待勾选」列就是需要在 OAuth App 选择器里补上的权限。