为什么 OAuth 的 client_id 不能当秘密:一次 Device OAuth 安全加固实践

前言

大家好,今天想分享一个我们在做 OAuth Device Flow 时遇到的真实问题。

Device Flow 很适合 CLI、桌面端、电视、IoT 这类不方便输入密码的场景。用户在设备上看到一个链接或验证码,打开浏览器完成授权,设备端再轮询 token。

但我们很快遇到一个安全困扰:

client_id 是公开的。
别人看到以后,完全可以说:
我不申请自己的 client_id 了,直接拿你的用。

这听起来像是 client_id 泄露问题,但本质上不是。OAuth 里的 client_id 本来就不是 secret。它只是应用标识,不是应用身份证明。

问题在哪里

原来的 Device Flow 大概是:

客户端 -> /oauth2/device_authorization
带 client_id,拿 device_code / user_code

客户端 -> /oauth2/token
带 client_id + device_code 轮询 token

服务端能校验:

client_id 是否注册
scope 是否允许
device_code 是否属于 client_id
轮询 IP 是否一致

这些都有价值,但挡不住一个问题:

别人拿到 client_id
自己发起 device flow
用户完成授权
别人也能拿 token

因为服务端只知道“这是某个 client_id 的请求”,不知道“这是不是官方客户端的某个真实安装实例”。

不要把 client_secret 塞进客户端

一个直觉方案是:给客户端加 client_secret

但这在 CLI、桌面端、移动端里基本是假安全。只要 secret 跟客户端一起发出去,它迟早能被提取。混淆、加壳、硬编码都只是增加一点逆向成本,不是强认证。

所以我们换了个思路:

不要试图隐藏 client_id
而是让仅有 client_id 不够用

client instance 的想法

我们引入了 client_instance_id

它表示某一次安装、某台机器、某个本地运行实例。

流程是:

1. 客户端首次启动生成一对本地密钥
2. 私钥留在本机
3. 公钥上传给服务端
4. 服务端返回 client_instance_id
5. 后续 OAuth 请求都带 client_instance_id

注册接口类似:

POST /oauth2/client/instances/register

请求里带:

{
  "client_id": "xxx",
  "public_jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "...",
    "y": "..."
  },
  "platform": "darwin-arm64",
  "version": "1.2.3"
}

服务端记录:

client_instance_id
client_id
public_jwk
jkt
platform
version
status

这里的 jkt 是 JWK thumbprint,也就是公钥指纹。

DPoP 是什么

仅有 client_instance_id 还不够,因为别人也可以伪造这个参数。

所以我们配合 DPoP。

DPoP 全称是 Demonstrating Proof of Possession。它解决的是:

请求方不只是知道一个 ID
它还必须证明自己持有某把私钥

客户端每次请求时都会加一个 header:

DPoP: <signed-jwt>

这个 JWT 由客户端本地私钥签名。里面包含:

{
  "htu": "https://auth.example.com/oauth2/token",
  "htm": "POST",
  "iat": 1710000000,
  "jti": "random-id"
}

服务端可以校验:

签名是否有效
htu 是否是当前 URL
htm 是否是当前 method
iat 是否在时间窗口内
jti 是否重放
proof 里的公钥 thumbprint 是否等于注册实例的 jkt

这样 client_instance_id 回答:

你声称自己是哪个实例?

DPoP 回答:

你真的持有这个实例登记过的私钥吗?

DPoP 解决什么,不解决什么

DPoP 很有价值,但不要误解它。

它能解决:

偷到 device_code 也不一定能 poll token
偷到 access token 也不一定能调用 API
偷到 refresh token 也不一定能刷新

前提是服务端真的做了绑定和校验。

但它不能单独解决:

别人拿你的 client_id 自己生成一把 key
然后完整发起一次新的 device flow

所以 DPoP 不是“官方客户端证明”。它证明的是“私钥持有”。

如果要证明这是官方客户端,还需要叠加:

实例注册准入
版本白名单
发布签名
平台 attestation
scope 分级
限流和风控

这次实践的结论

这次实践给我的最大感受是:

client_id 不是 secret
不要把公开标识当认证

更合理的做法是把风险拆开:

client_id 标识应用
client_instance_id 标识安装实例
DPoP 证明私钥持有
scope 和策略控制权限
灰度开关控制上线风险

最终目标不是让 client_id 变得不可见,而是让“只拿到 client_id”不再足够。

这就是我们这次 Device OAuth 加固的实践。后续真正打开服务端校验时,关键会是三件事:

device_code 绑定实例
refresh_token 绑定实例
API token 绑定 DPoP proof

到了那一步,Device Flow 才会从“谁知道 client_id 谁能发起”,逐步变成“只有被登记、能证明私钥持有的实例,才能稳定完成授权链路”。

文章摘自:https://www.cnblogs.com/jmcui/p/20395531