admin 子站 refresh token 刷新
要点
- 密码登录那条线,主线很清楚:查用户、验密码、建 session、发 token
- 和密码登录那条 service 很像,这段函数一开始也先把后面一定会反复用到的东西准备好
- 真正的 refresh 流程,是从这里开始的
- JWT 验通过之后,函数立刻做了两件事
内容
1. refresh 这条线,真正麻烦的地方在哪
密码登录那条线,主线很清楚:查用户、验密码、建 session、发 token。
refresh 就不一样了。它看起来像只是“拿旧 token 换新 token”,真写起来却比登录更容易混乱。
因为这里不只是验一张 token 对不对,还得继续确认很多事:
- 这张 refresh token 是不是真的
- 它是不是 admin 这条线发出来的
- 它对应的 session 有没有被撤销
- 它自己有没有过期、有没有被撤销
- 它是不是已经被用过
- 并发刷新时,能不能保证只有一个请求成功
- 旧 token 和新 token 的 rotation 关系有没有写完整
所以读这段 service 时,脑子里最好一直盯着两件事:
第一,旧 refresh token 是怎么被一层层验下来的。
第二,旧 token 和新 token 之间的交接顺序是怎么写的。
2. 完整代码
// admin-token-refresh.ts
import { AdminTokenRefreshResponseSchema, type AdminTokenRefreshRequest } from '@repo/contracts'
import type { Context } from 'hono'
import type { ApiBindings } from '@/bindings'
import { getDb } from '@/db/client'
import { getApiEnv } from '@/env'
import {
adminRoleRequiredError,
refreshTokenInvalidError,
refreshTokenReplayedError,
sessionRevokedError,
} from '@/auth/errors'
import { issueAdminTokenPair, verifyRefreshToken } from '@/auth/jwt'
import {
findRefreshTokenForSession,
getAdminRolesForUser,
insertRefreshToken,
markRefreshTokenUsed,
revokeSession,
updateRefreshRotation,
} from '@/auth/repository'
import { hashTokenJti } from '@/auth/token-hash'
export async function handleAdminTokenRefresh(params: {
c: Context<{ Bindings: ApiBindings }>
payload: AdminTokenRefreshRequest
}) {
const { c, payload } = params
const db = getDb(c.env.DB)
const env = getApiEnv(c.env)
let claims
try {
claims = await verifyRefreshToken({
token: payload.refreshToken,
secret: env.JWT_REFRESH_SECRET,
})
} catch {
throw refreshTokenInvalidError()
}
const nowMs = Date.now()
const jtiHash = await hashTokenJti(claims.jti)
const currentToken = await findRefreshTokenForSession({
db: db,
jtiHash,
sessionId: claims.sid,
})
if (!currentToken || currentToken.applicationCode !== 'admin') {
throw refreshTokenInvalidError()
}
if (currentToken.sessionRevokedAtMs !== null) {
throw sessionRevokedError()
}
if (currentToken.revokedAtMs !== null || currentToken.expiresAtMs <= nowMs) {
throw refreshTokenInvalidError()
}
if (currentToken.usedAtMs !== null) {
await revokeSession({
db: db,
sessionId: currentToken.sessionId,
revokedAtMs: nowMs,
reason: 'refresh_token_replay',
})
throw refreshTokenReplayedError()
}
const markedUsed = await markRefreshTokenUsed({
db: db,
tokenId: currentToken.tokenId,
usedAtMs: nowMs,
})
if (!markedUsed) {
await revokeSession({
db: db,
sessionId: currentToken.sessionId,
revokedAtMs: nowMs,
reason: 'refresh_token_replay',
})
throw refreshTokenReplayedError()
}
const roles = await getAdminRolesForUser(db, claims.sub)
if (roles.length === 0) {
await revokeSession({
db: db,
sessionId: currentToken.sessionId,
revokedAtMs: nowMs,
reason: 'admin_role_missing',
})
throw adminRoleRequiredError()
}
const refreshExpiresAtMs = nowMs + env.REFRESH_TOKEN_TTL_SEC * 1000
const session = {
sessionId: currentToken.sessionId,
userId: claims.sub,
app: 'admin' as const,
roles,
expiresAtMs: refreshExpiresAtMs,
}
const tokenPair = await issueAdminTokenPair({
session,
accessSecret: env.JWT_ACCESS_SECRET,
refreshSecret: env.JWT_REFRESH_SECRET,
accessTtlSec: env.ACCESS_TOKEN_TTL_SEC,
refreshTtlSec: env.REFRESH_TOKEN_TTL_SEC,
})
await insertRefreshToken({
db: db,
tokenId: tokenPair.refreshJti,
sessionId: currentToken.sessionId,
jtiHash: await hashTokenJti(tokenPair.refreshJti),
parentTokenId: currentToken.tokenId,
issuedAtMs: nowMs,
expiresAtMs: refreshExpiresAtMs,
})
await updateRefreshRotation({
db: db,
oldTokenId: currentToken.tokenId,
newTokenId: tokenPair.refreshJti,
sessionId: currentToken.sessionId,
lastSeenAtMs: nowMs,
})
return AdminTokenRefreshResponseSchema.parse({
accessToken: tokenPair.accessToken,
refreshToken: tokenPair.refreshToken,
tokenType: 'Bearer',
expiresInSec: env.ACCESS_TOKEN_TTL_SEC,
refreshExpiresInSec: env.REFRESH_TOKEN_TTL_SEC,
session,
})
}3. 开头先把上下文准备好
和密码登录那条 service 很像,这段函数一开始也先把后面一定会反复用到的东西准备好:
// admin-token-refresh.ts
const { c, payload } = params
const db = getDb(c.env.DB)
const env = getApiEnv(c.env)这里没什么花活。
payload里装的是前端传上来的 refresh tokendb是后面 repository 方法要用的数据库客户端env里装的是 refresh secret、access secret 和两类 token 的 TTL
这一步做完之后,后面整条 refresh 流程就能一直顺着往下走。
4. 第一层:先验这张 refresh token 本身对不对
真正的 refresh 流程,是从这里开始的:
// admin-token-refresh.ts
let claims
try {
claims = await verifyRefreshToken({
token: payload.refreshToken,
secret: env.JWT_REFRESH_SECRET,
})
} catch {
throw refreshTokenInvalidError()
}这里的意思很直接。
前端先把 refresh token 带上来,service 先交给 verifyRefreshToken(...) 去验。验的内容包括签名、过期时间、claims 结构这些基础合法性。
如果这一步就过不去,后面根本没必要再去查数据库了,直接收口成:
// admin-token-refresh.ts
throw refreshTokenInvalidError()也就是说,到这里为止,系统回答的问题只是:这张 token 在 JWT 这一层是不是一张像样的 refresh token。
5. 第二层:把 token 里的 jti 变成数据库能查的键
JWT 验通过之后,函数立刻做了两件事:
// admin-token-refresh.ts
const nowMs = Date.now()
const jtiHash = await hashTokenJti(claims.jti)nowMs 很好理解,后面一连串状态判断都要用当前时间。
jtiHash 更关键。
refresh token 里带着 jti,但后面查数据库时,不是直接拿原始 jti 去查,而是先做了一次 hash。这样数据库里真正匹配的是 hash 之后的值。
接着就把这个 hash 和 session id 一起拿去查当前 token 记录:
// admin-token-refresh.ts
const currentToken = await findRefreshTokenForSession({
db: db,
jtiHash,
sessionId: claims.sid,
})到这里,refresh 流程就从“验 JWT”进入了“验数据库状态”这一步。
6. 第三层:先确认数据库里真的有这张 token
查完数据库之后,第一道判断是:
// admin-token-refresh.ts
if (!currentToken || currentToken.applicationCode !== 'admin') {
throw refreshTokenInvalidError()
}这一步其实在做两件事。
第一件事,确认数据库里真的能找到这张 refresh token 的记录。
第二件事,确认它属于 admin 这条线,而不是别的 application。
只要其中一条不满足,还是直接收口成 refreshTokenInvalidError()。
这说明 service 对外并不想暴露太细的内部状态差异。找不到、类型不对、application 不对,在这里都统一算无效 token。
7. 第四层:session 有没有被撤销
接下来单独看 session 状态:
// admin-token-refresh.ts
if (currentToken.sessionRevokedAtMs !== null) {
throw sessionRevokedError()
}这一句说明 refresh 不只是看 token 自己,还要看它挂在哪个 session 上。
如果整个 session 已经被撤销了,那这张 refresh token 就算自己没过期、没标记 used,也不该继续发新 token。
这里抛的是:
sessionRevokedError()
这和前面的 refreshTokenInvalidError() 分开了,说明“session 已撤销”在业务上被单独当成一类状态处理。
8. 第五层:token 自己有没有过期或被撤销
session 还活着,接着再看 token 自己:
// admin-token-refresh.ts
if (currentToken.revokedAtMs !== null || currentToken.expiresAtMs <= nowMs) {
throw refreshTokenInvalidError()
}这里看的是两件事:
revokedAtMs !== nullexpiresAtMs <= nowMs
也就是这张 token 有没有被撤销,或者有没有过期。
这一步也很好理解。refresh token 本身如果已经失效,后面当然不能继续参与 rotation。
9. 第六层:如果它已经被用过,那事情就不是“普通失效”了
前面那些判断都还是“这张 token 是否还有效”。
到了这里,事情开始变得敏感:
// admin-token-refresh.ts
if (currentToken.usedAtMs !== null) {
await revokeSession({
db: db,
sessionId: currentToken.sessionId,
revokedAtMs: nowMs,
reason: 'refresh_token_replay',
})
throw refreshTokenReplayedError()
}usedAtMs !== null 的意思,就是这张 refresh token 之前已经成功用过一次了。
refresh token 一旦被设计成 rotation 模式,就不应该被重复使用。只要它已经被用过,还再次出现在这里,系统就会把它当成 replay 风险处理。
这里的处理也很干脆:
- 先撤销整个 session
- 再抛
refreshTokenReplayedError()
所以这一步和“过期了”“失效了”不一样。它不是单纯不给续签,而是把整条 session 直接收掉。
10. 第七层:真正的并发竞争点在 markRefreshTokenUsed
前面那一步拦住的是“已经明显被用过的 token”。
但并发刷新最麻烦的地方在于:两个请求可能几乎同时进来,在它们各自读 usedAtMs 时,看到的都还是 null。
这时候真正决定谁赢谁输的,是这一步:
// admin-token-refresh.ts
const markedUsed = await markRefreshTokenUsed({
db: db,
tokenId: currentToken.tokenId,
usedAtMs: nowMs,
})它的作用,就是去抢这张旧 token 的“used 标记”。
后面的判断非常关键:
// admin-token-refresh.ts
if (!markedUsed) {
await revokeSession({
db: db,
sessionId: currentToken.sessionId,
revokedAtMs: nowMs,
reason: 'refresh_token_replay',
})
throw refreshTokenReplayedError()
}也就是说,只有第一个成功把旧 token 标成 used 的请求,才有资格继续往下走。
后面那些抢输的并发请求,不会继续发新 token,而是直接把整条 session 撤掉,再抛 replay 错误。
这一步其实就是整条 refresh 链里最容易写乱、也最关键的地方。
旧 token 一定要先抢占成功,再谈后面的新 token。顺序不能反。
11. 第八层:refresh 时还要重新查一次角色
并发问题处理完之后,函数又回头查了一次角色:
// admin-token-refresh.ts
const roles = await getAdminRolesForUser(db, claims.sub)
if (roles.length === 0) {
await revokeSession({
db: db,
sessionId: currentToken.sessionId,
revokedAtMs: nowMs,
reason: 'admin_role_missing',
})
throw adminRoleRequiredError()
}这一步非常有现实意义。
因为 refresh 不是单纯复刻旧 session,而是一次新的续签动作。既然是重新发 token,就顺手再看一遍这个人现在还有没有 admin 角色。
如果角色已经没了,系统不会继续给他续签,而是:
- 先撤销 session
- 再抛
adminRoleRequiredError()
这就把“后台权限变更”及时收进了下一次 refresh。
12. 旧 token 这边都处理完了,才开始准备新 session 视角的数据
到这里,旧 refresh token 该验证的都验证完了。接下来才轮到准备新 token 要用的数据:
// admin-token-refresh.ts
const refreshExpiresAtMs = nowMs + env.REFRESH_TOKEN_TTL_SEC * 1000
const session = {
sessionId: currentToken.sessionId,
userId: claims.sub,
app: 'admin' as const,
roles,
expiresAtMs: refreshExpiresAtMs,
}这里没有重新创建一个全新的 session,而是沿用原来的 sessionId。
这很合理,因为 refresh 并不是重新登录,而是沿着当前 session 续签一轮新的 token。
不过新的 refresh 过期时间会重新按当前时间往后推,所以这里重新算了:
refreshExpiresAtMs
然后把当前续签这一轮需要的 session 视角数据整理出来,交给下面的 JWT 模块继续用。
13. 第九层:签一对新的 token
// admin-token-refresh.ts
const tokenPair = await issueAdminTokenPair({
session,
accessSecret: env.JWT_ACCESS_SECRET,
refreshSecret: env.JWT_REFRESH_SECRET,
accessTtlSec: env.ACCESS_TOKEN_TTL_SEC,
refreshTtlSec: env.REFRESH_TOKEN_TTL_SEC,
})这里和密码登录那条线很像,都是直接调:
issueAdminTokenPair(...)
也就是说,refresh 流程在真正发 token 这一层,并没有自己手写 JWT 细节,而是继续复用那套“给 admin session 发一对 token”的工具方法。
service 这一层只需要把:
- session
- secret
- TTL
这些材料准备好,然后拿回新的 access token 和 refresh token。
14. 第十层:先把新 token 写进去
签完新 token 之后,先做的是插入新 token 的数据库记录:
// admin-token-refresh.ts
await insertRefreshToken({
db: db,
tokenId: tokenPair.refreshJti,
sessionId: currentToken.sessionId,
jtiHash: await hashTokenJti(tokenPair.refreshJti),
parentTokenId: currentToken.tokenId,
issuedAtMs: nowMs,
expiresAtMs: refreshExpiresAtMs,
})这里最值得看的,是:
tokenId: tokenPair.refreshJtiparentTokenId: currentToken.tokenId
这说明新 token 和旧 token 的关系已经被串起来了。
新 token 不是孤零零插进去的,它会明确指向自己的父 token,也就是这次被拿来续签的那一张旧 refresh token。
到这里为止,数据库里已经有了这张新 token 自己的状态记录。
15. 第十一层:再回头把旧 token 指向新 token
新 token 插进去之后,才轮到更新 rotation 关系:
// admin-token-refresh.ts
await updateRefreshRotation({
db: db,
oldTokenId: currentToken.tokenId,
newTokenId: tokenPair.refreshJti,
sessionId: currentToken.sessionId,
lastSeenAtMs: nowMs,
})这一步做的事,可以直接理解成两句:
- 旧 token 现在已经被新 token 替换了
- 这个 session 刚刚又活跃了一次
所以这一步不是“创建新 token”,而是把旧 token 和新 token 之间的交接关系补完整。
这也就是 refresh 这条线最核心的 rotation 写入顺序:
- 先抢占旧 token 的 used 标记
- 再签新 token
- 再插入新 token 记录
- 最后回写 old -> new 的替换关系
顺序乱了,整条 refresh 链就容易出问题。
16. 最后一步:把这一轮续签结果整理成响应
// admin-token-refresh.ts
return AdminTokenRefreshResponseSchema.parse({
accessToken: tokenPair.accessToken,
refreshToken: tokenPair.refreshToken,
tokenType: 'Bearer',
expiresInSec: env.ACCESS_TOKEN_TTL_SEC,
refreshExpiresInSec: env.REFRESH_TOKEN_TTL_SEC,
session,
})前面的工作都做完之后,最后一步才是把响应体整理好。
这里返回的内容和登录那条线很像:
- 新 access token
- 新 refresh token
- token type
- access token 过期秒数
- refresh token 过期秒数
- 当前 session 视角的数据
而且同样不是直接 return 普通对象,而是:
AdminTokenRefreshResponseSchema.parse(...)
这样响应结构就继续和契约层保持一致
NOTE
退出登录的逻辑,这里就不再文章中赘述,大家可以根据源码自行阅读学习