令牌、刷新与撤销
access / refresh 令牌的生命周期、轮换与重放检测、撤销与自省、Bearer 与 DPoP 两种携带方式,以及存储与日志纪律。
令牌一览
| 令牌 | 形态 | 用途 |
|---|---|---|
| access token | at_ + 43 字符 base64url | 调用业务接口 |
| refresh token | rt_ + 43 字符 base64url | 换新 access token(每次轮换) |
| device code | dc_ + 43 字符 base64url | 设备码流轮询(见设备码流) |
access token 是不透明串,不是 JWT:没有可本地解析的声明,任何"离线验签"的思路都不适用。
要知道它还有效多久,读响应里的 expires_in。
生命周期
| 对象 | 默认有效期 | 备注 |
|---|---|---|
| access token | 600 秒 | 由平台配置(IDENTITY_ACCESS_TOKEN_TTL_SECONDS),以响应的 expires_in 为准 |
| refresh token | 30 天 | 每次刷新都换新的,旧的立即作废 |
| 授权码 | 10 分钟 | 一次性;换过就作废 |
| 待授权请求 | 5 分钟 | 用户在 App 里确认的窗口 |
| device code / user code | 600 秒 | 轮询间隔 5 秒 |
| id_token | 1 小时 | exp = iat + 3600 |
有效期可能被平台运维调整,不要把它写死在你的鉴权中间件里 —— 一律用响应里的 expires_in
推算过期时刻。
怎么带令牌
响应里的 token_type 决定一切:
token_type | 请求头 |
|---|---|
Bearer | Authorization: Bearer at_… |
DPoP | Authorization: DPoP at_… + 每次请求一个 DPoP: <proof> |
新建的应用默认 require_dpop = false。只有你在换令牌时主动带了 DPoP proof,令牌才会变成
DPoP 绑定;不带你拿到的是普通 Bearer 令牌。两种令牌混用会 401。
刷新
curl -sS https://auth.rankhub.cc/v1/oauth/token \
-H 'content-type: application/x-www-form-urlencoded' \
-d 'grant_type=refresh_token' \
-d 'refresh_token=<上一次的 refresh_token>' \
-d 'client_id=your-client-id'机密客户端同样要带客户端认证(Basic 或 body client_secret)。
响应是新的 access + 新的 refresh(旧的立刻作废):
{
"access_token": "at_…",
"token_type": "Bearer",
"expires_in": 600,
"scope": "openid profile",
"refresh_token": "rt_…",
"id_token": "…"
}三条硬规则:
- 每次都保存新的 refresh token,旧值不要再用。
- 刷新必须串行化。并发刷新必然产生"旧值再刷一次" ⇒ 服务端判定为重放(见下)。
- 刷新失败不要无限重试:401
invalid_grant基本等于"用户需要重新授权"。
重放 = 整族撤销
用已经轮换过的 refresh token 再刷一次,会被判为令牌泄露:服务端把整个令牌族标记为
compromised、清掉族下所有令牌,返回 401 invalid_grant。用户必须重新走一次授权。
表现上就是"某个用户突然全部掉登录",通常是客户端并发刷新或持久化写失败导致的。
刷新还要求:client_id 与签发时同一个、DPoP 令牌的 proof 用同一把密钥(换了 key 同样按泄露处理)。
账号被禁用时刷新返回 403(error 仍是 invalid_grant)。
撤销
curl -sS -X POST https://auth.rankhub.cc/v1/oauth/revoke \
-d 'token=<refresh_token 或 access_token>'
# → 200 {}- 不需要客户端认证(RFC 7009 允许),拿着令牌本身就能撤;
- 幂等:令牌不存在 / 已过期 / 已撤销也返回
200 {},不会暴露令牌状态; - 撤销任一令牌 = 撤销整个令牌族(同一用户 + 同一客户端 + 同一设备的整串令牌);
- 用户登出、发现令牌泄露、卸载应用时都应该调它。
自省端点同样没有客户端认证
POST /v1/oauth/introspect(body token=…)也是无认证的:谁能拿到令牌,谁就能查它。
所以不要把这个端点当成服务端校验手段去托管在不可信环境,更不要用它判断"某个令牌是否属于某用户"
之外的权限问题 —— 你有令牌就自己调接口,让接口回 401 即可。
curl -sS -X POST https://auth.rankhub.cc/v1/oauth/introspect -d 'token=<access_token>'{
"active": true,
"sub": "…",
"client_id": "…",
"scope": "openid profile",
"token_type": "DPoP",
"exp": 1760000000,
"iat": 1759999400,
"cnf": { "jkt": "…" }
}两个坑:
- 令牌无效时只返回
{ "active": false },其它键全部省略; - 有效令牌的
token_type恒为"DPoP"(即使是 Bearer 令牌)。不要按自省结果的token_type决定发什么头 —— 发令牌时响应里的token_type才是权威。
令牌怎么存
| 场景 | 建议 |
|---|---|
| 服务端(机密客户端) | 加密存储,按用户维度隔离;刷新任务串行化(每用户一把锁) |
| 浏览器 SPA(公开客户端) | access token 只放内存;refresh token 放内存或 sessionStorage。放 localStorage 等于接受 XSS 可读的长期凭据 |
| 移动端 / 桌面端 | 系统钥匙串 / Keychain;不要把 refresh token 写明文文件 |
| 日志 / 埋点 | 按 at_ / rt_ / dc_ 前缀脱敏;绝不记录 code、DPoP proof、nonce、state |
401 / 403 / 428 / 429 怎么理解
调用 https://api.rankhub.cc 上的接口时:
| 状态 | 含义 | 该怎么办 |
|---|---|---|
| 401 | 明确未认证:令牌无效/过期/被撤、缺令牌、或 scope 不足以外的鉴权失败 | 刷新令牌;刷新也失败就重新授权 |
| 403 | 已认证但无权:scope 不够、该形状只给第一方、写入被暂停、或删除要用户同意 | 看业务码:requires <scope> 补 scope 并重新授权;game.firstPartyOnly / game.writeSuspended / game.deletionConsentRequired 不要重试 |
| 428 | 写入需要用户先确认 | 带 data.confirmation.confirmationToken 重放同一份 payload |
| 429 | 触发限流 | 按 Retry-After 退避 |
| 426 | App 版本过低(仅原生 App 登录态) | 升级客户端 |
| 503 | 判定服务暂时不可用 | 可重试,别当成"未登录"清掉用户会话 |
503 是"说不清",401 才是"明确不行"。把 503 当 401 处理(清会话)会在平台抖动时
把所有用户踢下线 —— 这也是我们刻意区分两者的原因。
想把令牌绑到密钥上(DPoP,可选)
如果客户端能在换令牌时提供 DPoP proof(RFC 9449),令牌就会绑定到那对密钥:
- 换令牌时带
DPoP: <proof>(htm= 方法、htu= 完整 URL、jti唯一、iat当前时间), 响应会变成token_type: "DPoP"并带cnf.jkt; - 之后每个请求:
Authorization: DPoP at_…+DPoP: <proof>,proof 里加ath = BASE64URL(SHA256(access_token)); - 服务端可能回
401+WWW-Authenticate: DPoP error="use_dpop_nonce"+DPoP-Nonce头 —— 把该 nonce 放进下一次 proof 的nonce声明重试即可(一次自愈); - 刷新时必须用同一把密钥,否则按泄露处理、整族撤销。
普通 Web 应用用不到它;需要"令牌被偷了也不能用"的场景才值得上。