Exia Blog. 写字的地方

OAuth Device Flow 是怎么在不配回调域名的情况下完成登录的

Exia-blip 2026-11-03 12 分钟阅读 更新于 2026-11-08

结论

Device Flow 把「浏览器跳回来」换成了「用户手抄一个短码」,所以客户端不需要可被公众访问的回调地址,也不需要 client_secret。授权结果不是被重定向送回来的,而是客户端拿着 device_code 主动去轮询拿的。

这一条差异,决定了它能在 CLI、电视、路由器后台、没有本地 HTTP 服务的桌面应用里跑通——这些场景里,「回调地址」根本不存在。

先看清三种流程的分界

流程谁来接收授权结果需要回调地址需要 client_secret典型场景
Authorization Code浏览器重定向到 redirect_uri要,且必须公网可达要(Web 后端换 token)普通网站登录
Authorization Code + PKCE同上,但用 code_verifier 替代 secret要,可用自定义 scheme不要移动 App / SPA
Device Flow客户端轮询不要不要CLI、TV、无浏览器设备

前两种的隐含前提是:授权服务器能把结果推给客户端。Device Flow 把方向反过来——客户端自己拉。

前两种的细节分别在第 1 篇和第 2 篇里拆过:它们的分歧只在「用 secret 还是用 verifier 证明身份」,共同点都是必须有一个能收到回调的地址。这一篇讲的是这个共同点不成立时怎么办。

Device Flow 的五步

以 GitHub 为例,端点是真的,字段名也是真的。

第一步:申请设备码

bash
curl -X POST https://github.com/login/device/code \
  -H "Accept: application/json" \
  -d "client_id=Iv1.xxxxxxxxxxxx" \
  -d "scope=read:user"

返回:

json
{
  "device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5",
  "user_code": "WDJB-MJHT",
  "verification_uri": "https://github.com/login/device",
  "expires_in": 900,
  "interval": 5
}

注意 verification_uri 是授权服务器自己的地址,不是你的回调地址。这就是它不需要回调域名的原因:用户从头到尾都在 GitHub 的地盘上操作。

第二步:把 user_code 和 verification_uri 展示给用户

CLI 里通常是这么一行:

text
请在浏览器打开 https://github.com/login/device
并输入代码:WDJB-MJHT

第三步:用户在自己设备上打开、输入、点授权

这一步完全脱离你的客户端。你的程序此时只是挂在那里。

第四步:客户端按 interval 轮询

bash
curl -X POST https://github.com/login/oauth/access_token \
  -H "Accept: application/json" \
  -d "client_id=Iv1.xxxxxxxxxxxx" \
  -d "device_code=3584d83530557fdd1f46af8289938c8ef79f9dc5" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:device_code"

用户还没点授权时,返回的是 200 加一个错误字段:

json
{ "error": "authorization_pending" }

第五步:拿到 token

json
{ "access_token": "gho_xxxxxxxx", "token_type": "bearer", "scope": "read:user" }

轮询期间会遇到的几个错误码,含义和应对方式不一样,别一律当失败处理:

error含义该怎么办
authorization_pending用户还没操作继续按 interval 轮询
slow_down轮得太快interval 增加 5 秒,不要重试原频率
expired_token设备码过期(GitHub 是 900 秒)回到第一步,重新申请
access_denied用户点了拒绝停止,提示用户
incorrect_device_code码不对或已用过回到第一步

slow_down 是 RFC 8628 里明确规定的:收到它之后必须把间隔加 5 秒。忽略这条的客户端会被授权服务器直接拉黑。

机制上的关键:user_code 是怎么把两边对上的

这是整个流程里最容易被忽略的一环。

  • device_code:长、随机、只在你和授权服务器之间流转,永远不给人看。
  • user_code:短、可读、给人看,用户把它输进浏览器。

授权服务器内部维护一张映射:user_code → 待授权的 device_code。用户在浏览器里授权的是那个 user_code,服务器把授权结果写到对应的 device_code 记录上。客户端下一次轮询时,拿 device_code 一问,就能取到自己那份 token。

于是「两边」通过服务器上的这张表对接,客户端和浏览器之间没有任何直接通信,也就不需要回调地址——这也是它能穿过 NAT、穿透公司防火墙的原因。

本站用的是 Authorization Code,不是 Device Flow

说清楚一点,免得看完上面以为这个博客的登录是 Device Flow。

本站的登录走的是标准 Authorization Code:

text
GET /auth/github
  → 302 到 https://github.com/login/oauth/authorize
         ?client_id=...&redirect_uri=https://exia.de5.net/auth/callback&state=<随机值>
  → 用户授权
  → 302 回 /auth/callback?code=...&state=<同一个值>
  → 校验 state → POST /login/oauth/access_token 换 token → 写 session cookie

redirect_uri 在 GitHub App 里注册过,站点域名固定、HTTPS 可用,所以完全没有理由绕到 Device Flow 去。浏览器场景下,Authorization Code 少一步手抄、体验更好,state 还能顺手防 CSRF。

Device Flow 是给「没有浏览器」的场景准备的。硬要在网页上用,等于让用户从浏览器跳到另一个页面抄一串码再回来,纯属找罪受。

什么时候才该用它

  • CLI 工具:gh auth login 就是这条路,本机起不了公网 HTTP 服务。
  • 电视 / 车机 / 物联网设备:输入法都难用。
  • 桌面应用:不想为每个平台注册自定义 scheme,也不想内嵌 WebView 抓回调。
  • 临时脚本:不想为了跑一次脚本去注册回调地址。

反过来说,只要你的运行环境有浏览器、域名可配,就别用。

它的安全边界

Device Flow 不是「更安全的 OAuth」,它是在无法回调的前提下,把风险重新分配的方案。

  1. 短码可被钓鱼:攻击者申请一个 device_code,把 user_code 发给受害者,骗他去 GitHub 输入并授权。受害者以为在登录自己的账号,实际授权的是攻击者的设备。防护只能靠授权页面始终显示「哪个应用在请求什么权限」,以及用户自己看清。这也是 Device Flow 通常只允许低风险 scope 的原因。
  2. 轮询是天然的资源消耗点:授权服务器必须对它做限流,slow_down 就是限流信号本身。
  3. token 直接落到客户端:没有后端做中转,客户端一旦不可信,token 就是裸的。桌面/CLI 场景能接受,因为它本来就在用户自己的机器上。
  4. client_secret 不参与:公共客户端没有秘密可言,安全性全部押在「用户确实在自己的浏览器里点了授权」。

一份最小实现骨架

js
async function deviceLogin({ clientId, scope }) {
  const start = await fetch('https://github.com/login/device/code', {
    method: 'POST',
    headers: { accept: 'application/json' },
    body: new URLSearchParams({ client_id: clientId, scope }),
  }).then((r) => r.json());

  console.log(`打开 ${start.verification_uri} ,输入 ${start.user_code}`);

  let interval = start.interval || 5;
  const deadline = Date.now() + (start.expires_in || 900) * 1000;

  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, interval * 1000));

    const res = await fetch('https://github.com/login/oauth/access_token', {
      method: 'POST',
      headers: { accept: 'application/json' },
      body: new URLSearchParams({
        client_id: clientId,
        device_code: start.device_code,
        grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
      }),
    }).then((r) => r.json());

    if (res.access_token) return res.access_token;
    if (res.error === 'authorization_pending') continue;
    if (res.error === 'slow_down') { interval += 5; continue; }
    throw new Error(res.error || 'device flow failed');
  }
  throw new Error('expired_token');
}

三件事别写错:grant_type 那一长串不能省;slow_down 要加间隔而不是重试;循环必须有 expires_in 兜底,否则用户不操作时这个进程会一直转。

一句话总结

Device Flow 的本质不是「另一种 OAuth」,而是把回调这件事从网络层挪到了人身上:用户抄一次码,客户端就不用被公网访问。理解了这一点,它该用在哪儿、代价是什么,也就清楚了。

三种流程讲完,「怎么拿到 token」这一半就结束了。另一半问题没人管:token 拿到之后活多久、什么时候失效、怎么撤销——各家答案都不一样,而且没有一个能靠猜:token 到手之后:过期、刷新、撤销分别归谁管。

#oauth #http #workers

评论

还没有评论,来抢沙发。