RankHub

令牌、刷新与撤销

access / refresh 令牌的生命周期、轮换与重放检测、撤销与自省、Bearer 与 DPoP 两种携带方式,以及存储与日志纪律。

令牌一览

令牌形态用途
access tokenat_ + 43 字符 base64url调用业务接口
refresh tokenrt_ + 43 字符 base64url换新 access token(每次轮换)
device codedc_ + 43 字符 base64url设备码流轮询(见设备码流)

access token 是不透明串,不是 JWT:没有可本地解析的声明,任何"离线验签"的思路都不适用。 要知道它还有效多久,读响应里的 expires_in。

生命周期

对象默认有效期备注
access token600 秒由平台配置(IDENTITY_ACCESS_TOKEN_TTL_SECONDS),以响应的 expires_in 为准
refresh token30 天每次刷新都换新的,旧的立即作废
授权码10 分钟一次性;换过就作废
待授权请求5 分钟用户在 App 里确认的窗口
device code / user code600 秒轮询间隔 5 秒
id_token1 小时exp = iat + 3600

有效期可能被平台运维调整,不要把它写死在你的鉴权中间件里 —— 一律用响应里的 expires_in 推算过期时刻。

怎么带令牌

响应里的 token_type 决定一切:

token_type请求头
BearerAuthorization: Bearer at_…
DPoPAuthorization: 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": "…"
}

三条硬规则:

  1. 每次都保存新的 refresh token,旧值不要再用。
  2. 刷新必须串行化。并发刷新必然产生"旧值再刷一次" ⇒ 服务端判定为重放(见下)。
  3. 刷新失败不要无限重试: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 退避
426App 版本过低(仅原生 App 登录态)升级客户端
503判定服务暂时不可用可重试,别当成"未登录"清掉用户会话

503 是"说不清",401 才是"明确不行"。把 503 当 401 处理(清会话)会在平台抖动时 把所有用户踢下线 —— 这也是我们刻意区分两者的原因。

想把令牌绑到密钥上(DPoP,可选)

如果客户端能在换令牌时提供 DPoP proof(RFC 9449),令牌就会绑定到那对密钥:

  1. 换令牌时带 DPoP: <proof>(htm = 方法、htu = 完整 URL、jti 唯一、iat 当前时间), 响应会变成 token_type: "DPoP" 并带 cnf.jkt;
  2. 之后每个请求:Authorization: DPoP at_… + DPoP: <proof>,proof 里加 ath = BASE64URL(SHA256(access_token));
  3. 服务端可能回 401 + WWW-Authenticate: DPoP error="use_dpop_nonce" + DPoP-Nonce 头 —— 把该 nonce 放进下一次 proof 的 nonce 声明重试即可(一次自愈);
  4. 刷新时必须用同一把密钥,否则按泄露处理、整族撤销。

普通 Web 应用用不到它;需要"令牌被偷了也不能用"的场景才值得上。

本页内容