cloudflare

接入 Cloudflare OAuth Client 最佳实践

最近自己把一个第三方应用接到 Cloudflare 自管 OAuth Client,从创建客户端到走通授权码的实践记录。

约四千六百字·读约十四分钟 · English

接入 Cloudflare OAuth Client 最佳实践

最近把一个第三方应用接到 Cloudflare 的自管 OAuth Client 上,才发现控制台里那些字段不是「填了就能过」。客户端类型选错、redirect_uri 差一个斜杠、过早把 private 升成 public,后面每一层报错都会指到别的地方。

这套东西解决的是:让你的应用以用户身份去调 Cloudflare API,而不是把一枚长期 API Token 塞进客户端。用户在 dash.cloudflare.com 上点同意之后,你拿到 access token,需要续期再拿 refresh token,然后用它们访问 Workers、账号、分析这类接口。

它不是「用 Cloudflare 账号登录你的产品」那种登录按钮。openidprofileemail 这些 Identity scope 确实能拿到用户资料,但授权页真正在问的是:这个应用能对哪些 Cloudflare 资源做什么。用户点同意,是在授权账号权限,不是在用 Cloudflare 当你的 IdP。

官方文档也写死了:第三方客户端只支持 Authorization Code。Client Credentials、Implicit、Resource Owner Password、Device Authorization 都没有。想绕开浏览器,没有第二条路。

端点都挂在 dash.cloudflare.com 下面:

用途URL
OpenID 配置https://dash.cloudflare.com/.well-known/openid-configuration
JWKShttps://dash.cloudflare.com/.well-known/jwks.json
授权https://dash.cloudflare.com/oauth2/auth
换 tokenhttps://dash.cloudflare.com/oauth2/token
吊销https://dash.cloudflare.com/oauth2/revoke
登出https://dash.cloudflare.com/oauth2/logout
用户信息https://dash.cloudflare.com/oauth2/userinfo

下面按接入顺序写:先选类型,再对照创建页把字段改对,然后走一遍授权码,最后把原生 App 和常见的坑单独拿出来。

先选对客户端类型

创建页上有一栏 Token Authentication Method。它决定的是:换 token 的时候,你的应用怎么证明自己是这个 client。常见就两种。

机密客户端带一份 client_secret。去 /oauth2/token 兑换时,除了授权码,还要出示这份 secret。控制台里对应 client_secret_basicclient_secret_post,差别只是 secret 放在 HTTP Basic 里还是放在表单字段里。前提是 secret 只能留在服务器上,用户看不到、也拿不走。

公开客户端没有 secret。控制台里对应的选项是 None (PKCE)。因为安装包和前端代码都会被拆开看,再发一份固定 secret 等于公开发布。它改用 PKCE:每次登录现场生成一段随机的 code_verifier,授权时只把派生出来的 code_challenge 交给 Cloudflare,兑换时再出示原来的 verifier。Cloudflare 要求 challenge 方法必须是 S256

对上自己的应用之后,选择就清楚了:

你的应用用哪种客户端Token Authentication MethodPKCE
服务端 Web / 后端机密客户端client_secret_basicclient_secret_post可选
SPA、移动、桌面、CLI公开客户端None (PKCE)必须,而且必须是 S256

只有选了 client_secret_basicclient_secret_post,才会生成 client_secret。创建成功后它会当场显示一次,离开页面就再也看不到,丢了只能轮转。这份 secret 只能留在服务端,放进环境变量或密钥管理,不要下发到浏览器。

SPA、移动、桌面、CLI 不要选这两项,保持 None (PKCE)。这样就不需要配置 client_secret

创建 Client 的关键字段

控制台路径是:选账号 → Manage Account → OAuth clients → Create client。Cloudflare 账号要带 Super Administrator、Administrator,或者 OAuth Client Write。也可以走 API:POST /accounts/{account_id}/oauth_clients,对应 token 需要 OAuth Clients Write

Create client 之后,这一页的标题是 Configure OAuth client。右侧三步写得很清楚:当前是 Configure OAuth client,后面是 Select permission scopesChoose optional scopes。标题下面有一段原文:

All clients are considered private and cannot be made public until the required fields are filled. Some optional fields are required for public clients.

新建客户端默认是 private。必填项没齐之前,不能改成 public。这一页上标成 optional 的字段,private 阶段可以空着;以后要升 public,官方要求先齐 Client name、Logo、Client URL、Scopes,并且完成 Client URL 的域名验证。

图 1:Create client 里的 Configure OAuth client

图 1:Configure OAuth client。

这一页从上到下是:Client Name、Response Type、Grant type、Token Authentication Method、Redirect (Callback) URLs、Client URL (optional),再下面有一栏收起的 Advanced options。填完点 Continue,进入第二步选权限。下面按字段说该怎么改。

Client Name

给自己看的名字,同意页上用户也会看到。先起一个能分清环境的名字,比如 myapp-dev,不要和生产混用同一个 client。

Response Type:只留 Code

图 1 里这一栏已经是 Code。保持这样就对。下拉里还能加上 Token、ID Token,那两项对应 Implicit:令牌会出现在 URL 的 #fragment 里。浏览器从来不会把 fragment 发给服务器。只要回调要经过你的后端,或者经过一个 HTTPS 中转,这条路就走不通——服务端只能看到 query string。

官方也不再支持 Implicit。不要把 Token 和 ID Token 加回去。留着它们不会多一条捷径,只会在授权页或兑换 token 时报错。

Grant type:Authorization Code,要续期再加 Refresh Token

图 1 里这一栏已经是 Authorization Code, Refresh Token。Authorization Code 必须留着。如果希望用户不用反复点同意,Refresh Token 也可以留,后面选 scope 时还要带上 offline_access。不需要静默续期,就把 Refresh Token 去掉。

openidoffline_access 这类协议 scope,会跟着你选的 Response Type 和 Grant type 自动增减,不用自己拼。

Token Authentication Method

这一栏就是上一节说的客户端类型。图 1 里默认是 None (PKCE)

  • 后端、服务端 Web:改成 client_secret_basicclient_secret_post
  • SPA、移动、桌面、CLI:保持 None (PKCE)

选了带 secret 的两项,创建成功后会当场显示 client_secret离开页面就再也看不到,丢了只能轮转。

Redirect (Callback) URLs

占位符是 https://example.com/callback。改成你真正要接回调的地址。官方只接受 https://myapp://oauth/callback 这种自定义 scheme 会被拒绝,这是原生 App 接入时最硬的一条限制。登出回调用同一套规则,不过它不在这一页。

桌面和 CLI 可以把 http://127.0.0.1:<port>/callback 这类回环地址直接填在这里,不必再架中转。但不要由此推论「任意 http 都行」:指向公网 IP 或普通域名的 http://,不该出现在这个字段里。

登记进去的每一条都是允许列表。授权请求里的 redirect_uri 必须和其中一条完全一致,包括 scheme、host、端口、路径,以及有没有尾斜杠。兑换 token 时还要再传一次,而且必须和授权那一次是同一个字符串。

Client URL(optional)

这一页上标了 optional。private 客户端可以先空着。以后要升 public,这里必须填,而且域名要通过 TXT 验证。路径以后还能改,域不行,所以不要随便写一个以后用不到的域名。

点 Continue 之后进入第二步 Select permission scopes,第三步是 Choose optional scopes。Scope 的名字很容易写错:不要发明冒号分隔的写法,account:read 会被拒,正确形态是 account.read 这种点号分隔。这些名字和 API Token 的权限一一对应,至少选一个。

默认全部都是 required,用户在同意页必须一次接受。只有放进 optional scopes 的权限,用户才能单独拒绝,而且它必须是已选 scopes 的子集。这就是向导第三步要做的事。

官方文档里的同意页就是这个样子:上面一块是 Required,用户没法关掉;下面 Additional access 才是可以点 Edit Permissions 的可选权限。

图 2:Cloudflare 同意页把权限分成 Required 和 Additional access

图 2:同意页先列出必选权限,可选权限放在 Additional access 里。

点进 Edit Permissions 之后,还可以按 Read only / Full access 过滤,或者直接搜某个 scope。这就是为什么创建时别把什么都勾成 required——勾成 optional,用户才有机会在这一页关掉。

图 3:在同意页里编辑可选权限

图 3:可选权限可以按项关掉,Required 那一项会一直留着。

当前用不到的权限就不要勾。勾进 Required 的,用户在同意页关不掉。

先 private,再决定要不要 public

新客户端默认是 private:只有创建它的那个 Cloudflare 账号里的成员能完成授权。内部工具、联调、平台自己的管理登录,用这个就够了。这一点和图 1 标题下那段原文一致。

要让任意 Cloudflare 用户都能授权,必须提升为 public。提升之前要满足:

  • Client name、Logo、Client URL 都填齐
  • 至少有一个不是 identity 的 scope
  • Client URL 的域名通过 TXT 验证。记录值要带 cloudflare_oauth_client_publisher= 这个前缀。Cloudflare 轮询最长大约两天,超时了可以点 Restart

升成 public 之后不能再降回去。 验证过的域名也不能改成另一个域,路径可以改,域不行。所以先用 private 把整条链路跑通,再决定要不要升。

授权码流程怎么走

一次完整登录是五步,对应下图。哪一步漏了,后面的报错多半会指错方向。

图 4:Authorization Code 加 PKCE 的认证时序

图 4:应用、浏览器、Cloudflare 之间的授权码流程。公开客户端用 code_verifier 换 token,机密客户端用 client_secret

  1. 应用自己生成 state(用来防 CSRF),以及 PKCE 需要的一对值:先准备一段高熵随机串作为 code_verifier,再算 code_challenge = BASE64URL(SHA256(verifier))
  2. 用浏览器打开授权页:
GET https://dash.cloudflare.com/oauth2/auth
  ?response_type=code
  &client_id=<client_id>
  &redirect_uri=<登记过的那一条>
  &scope=openid profile email offline_access account.read
  &state=<随机值,应用自己保存>
  &code_challenge=<S256(code_verifier)>
  &code_challenge_method=S256
  1. 用户同意之后,Cloudflare 会 302 到 redirect_uri?code=…&state=…,通常还会带上 RFC 9207 规定的 iss
  2. 先核对 state,再核对 iss。后者是为了防止 mix-up:确认发来授权码的,就是你以为的那台授权服务器。然后拿 code/oauth2/token 兑换——公开客户端带上 code_verifier,机密客户端带上 client_secret
  3. access token 和 refresh token 只放在这个平台真正能保密的地方。服务端用密钥库,iOS 用 Keychain,不要写进 UserDefaults、LocalStorage,更不要打进日志。

公开客户端的兑换请求大致是这样:

POST https://dash.cloudflare.com/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<回调拿到的 code>
&redirect_uri=<必须与授权时完全一致>
&client_id=<client_id>
&code_verifier=<发起时生成的那条>

code 只能用一次,而且活得很短。之后续期用 grant_type=refresh_token。用户退出时要调 /oauth2/revoke,不要只删本地那一份——本地删了,对面那枚 refresh token 往往还活着。

原生 App 的 HTTPS 墙

Cloudflare 不接受 myapp://,也不提供 Device Authorization。所以原生 App 必须先有一个真实的 HTTPS 地址把回调接住,再由这个地址把 codestate 送回 App。整条认证链路仍是图 4,只是第 3 步的 redirect_uri 先落到中转,再 302 进 App。

常见做法是自己架一个只做 302 的中转:把 https://<你的域>/oauth/callback/<appId> 登记到 Cloudflare 的 redirect_uris,中转再跳到 myapp://oauth/callback。中转不要保存 code,不要兑换 token,也不要持有 secret。state 校验和 PKCE 兑换,仍然应该在设备上完成。

接线时其实是三个不同的 URL。混用这三处,是原生接入失败最常见的原因:

填在哪里填什么示例
Cloudflare 的 redirect_urisHTTPS 中转地址https://relay.example.com/oauth/callback/k7m2xq9vn4bt3wp8
中转上登记的「回调地址」App 最后要跳回去的地方myapp://oauth/callback
ASWebAuthenticationSession.callbackURLScheme只填 scheme 名myapp

iOS 还要在 Info.plistCFBundleURLTypes 里声明 CFBundleURLSchemes。漏了这一步,系统不知道该由谁接手 myapp://,中转的 302 就会落空。

ASWebAuthenticationSession 时,建议打开 prefersEphemeralWebBrowserSession,少一次共享 Cookie 带来的账号串用。退出时同样要调 /oauth2/revoke,不要只清 Keychain。

常见的坑

1. redirect_uri 对不上,得到 invalid_grant

授权请求里的 redirect_uri,必须和创建客户端时登记的某一条完全一样,兑换 token 时还要再传一次,而且必须和授权那一次是同一个字符串。scheme、主机、端口、路径、有没有尾斜杠都不能差。原生 App 如果授权时填的是 HTTPS 中转,兑换时却写成 myapp://…,token 端点会失败。授权码只能用一次,失败后再拿同一条 code 去换,一样会失败。先对两次请求里的 redirect_uri,再查 code_verifier 是不是发起授权时生成的那一条。

2. Response Type 加回了 Token / ID Token

图 1 里默认已经是 Code。官方给第三方客户端只支持 Authorization Code,不支持 Implicit。下拉里如果再勾上 Token 或 ID Token,令牌会进 URL 的 #fragment。浏览器不会把 fragment 发给服务器,后端和中转都看不见。不要加回去。

3. SPA、移动、桌面、CLI 还选了带 secret 的两项

官方要求这类应用走 Authorization Code + PKCE:Token Authentication Method 选 None (PKCE),challenge 方法用 S256,不要用 plain。选了 client_secret_basicclient_secret_post,才会生成 client_secret;这类应用藏不住它。

4. 以为 private 客户端谁都能授权

新客户端默认是 private。官方写明:只有创建它的那个 Cloudflare 账号里的成员能完成授权。要让任意 Cloudflare 用户都能授权,必须先升成 public。升上去之后不能再改回 private。

5. 对方账号关了 Public OAuth App access

同意页上如果选不到对方那个账号,先让对方管理员看 Manage Account → Members → Settings → Public OAuth App access。官方说这个开关会限制 OAuth 应用访问该账号的资源,跟你这边的 client 字段无关。

6. 回调回来不校验 state

state 本来就会出现在授权 URL 和回调 URL 里,用来防 CSRF。发起授权时自己存一份随机值,比如服务端 session 或 App 内存;回调回来必须对得上。不要用固定值,也不要空着不传。

7. 回调里有 iss 却不核对

RFC 9207 用 iss 标明这枚授权码是哪台授权服务器发的。回调里如果带了这个参数,把它和 https://dash.cloudflare.com/.well-known/openid-configuration 里的 issuer 对一下。现在这个值是 https://dash.cloudflare.com,以后以配置为准,不要在代码里写死。

8. 日志里打印了 code 或 token

code 和 token 都是凭据。中转和回调处理只记结果、错误码,不要记 codestate 和 token,也不要打进 APM 或错误上报。

9. 用 GET 去换 token,或者把机密客户端的兑换放到浏览器里

token 端点只接受 POST。机密客户端的兑换要在服务器上做,不要把 client_secret 带到前端。公开客户端可以在设备上换,但不要把 token 响应打到浏览器控制台然后忘了删。

10. 轮转了 secret,却忘了删旧的

官方允许每个客户端同时有两个 secret:先创建新的,客户端改用新的,再删旧的。接口返回里的 has_rotated_secrettrue 时,必须先删掉旧的,才能再转一次。

11. 域名验证超时,就当成配置错了

升 public 之前,Client URL 的域名要做 TXT 验证。记录值必须原样带上 cloudflare_oauth_client_publisher= 这个前缀。Cloudflare 会轮询这条记录,直到找到,或者两天后超时。超时了先在客户端菜单里点 Restart verification,或者对同一个 client_uri 再发一次 PATCH。这不等于域名写错了。

12. 回调路径上开了 Challenge

原生 App 的回调如果要经过你自己的 HTTPS 中转,路径上不要开 Managed Challenge、Bot Fight 这类会打断跳转的验证。ASWebAuthenticationSession 里一旦弹出挑战页,授权就会停在半路。

最佳实践

只勾当前功能用得到的 scope。官方说这些名字和 API Token 权限对应,至少选一个;默认都是 required,用户在同意页关不掉。能放到 optional 的,就不要做成 required。

开发、预发、生产用三个 client,各自登记自己的 redirect_uris。不要把开发环境的回调地址写到生产 client 上。

选了 client_secret_basicclient_secret_post 时,官方只会在创建或轮转时显示一次 client_secret,离开页面就看不到。立刻写进密钥管理,不要放进聊天记录、issue 或截图。选了 None (PKCE) 就不需要配置 client_secret

官方要求每次登录都生成一对新的 PKCE:code_verifier 按 RFC 7636 用加密安全的随机数,长度 43 到 128 个字符;challenge 方法用 S256。单测可以用 RFC 7636 附录 B 的测试向量:

verifier  = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

redirect_uri 必须和登记的那一条完全一致。路径改了,已经发出去的授权请求会对不上。已经对外登记过的回调地址,不要轻易改。

联调先用 private。官方写明只有本账号成员能完成授权,这样失败时先查自己的代码和字段。

用户退出时调 https://dash.cloudflare.com/oauth2/revoke,不要只删本地那一份 token。用户也可以在控制台的 Manage OAuth authorizations 里自己吊销。

观测只记授权结果和错误码。不要把 codestate 和 token 写进日志。

授权、token、jwks、issuer 以 https://dash.cloudflare.com/.well-known/openid-configuration 为准,不要在代码里写死。

排障对照

现象先查什么
授权页直接失败,或同意页选不到对方账号客户端还是 private 吗?对方关了 Public OAuth App access 吗?scope 名字是不是带了冒号?
回调 404,或者提示「无法完成」redirect_uri 登记了吗?中转的 appId 写对了吗?应用是不是被停用了?
invalid_grant两次 redirect_uri 是不是同一个字符串?verifier 对得上吗?code 是不是已经用过了?
换到了 token,调 API 却 403access token 的 scope 覆盖那个接口了吗?用户对目标账号有对应权限吗?
刷新失败创建时勾了 Refresh Token,请求里也带了 offline_access 吗?refresh token 是不是已经被吊销了?
iOS 302 之后没有反应Info.plist 声明了 scheme 吗?callbackURLScheme 是不是只填了名字?

参考

Mttao

Mttao GitHub ↗

探索技术与生活的智慧

相关文章

/ 评论