创建时间: 2026-05-16最后更新: 2026-05-16

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
001
import { AdminTokenRefreshResponseSchema, type AdminTokenRefreshRequest } from '@repo/contracts'
002
import type { Context } from 'hono'
003
import type { ApiBindings } from '@/bindings'
004
import { getDb } from '@/db/client'
005
import { getApiEnv } from '@/env'
006
import {
007
adminRoleRequiredError,
008
refreshTokenInvalidError,
009
refreshTokenReplayedError,
010
sessionRevokedError,
011
} from '@/auth/errors'
012
import { issueAdminTokenPair, verifyRefreshToken } from '@/auth/jwt'
013
import {
014
findRefreshTokenForSession,
015
getAdminRolesForUser,
016
insertRefreshToken,
017
markRefreshTokenUsed,
018
revokeSession,
019
updateRefreshRotation,
020
} from '@/auth/repository'
021
import { hashTokenJti } from '@/auth/token-hash'
022
023
export async function handleAdminTokenRefresh(params: {
024
c: Context<{ Bindings: ApiBindings }>
025
payload: AdminTokenRefreshRequest
026
}) {
027
const { c, payload } = params
028
const db = getDb(c.env.DB)
029
const env = getApiEnv(c.env)
030
031
let claims
032
033
try {
034
claims = await verifyRefreshToken({
035
token: payload.refreshToken,
036
secret: env.JWT_REFRESH_SECRET,
037
})
038
} catch {
039
throw refreshTokenInvalidError()
040
}
041
042
const nowMs = Date.now()
043
const jtiHash = await hashTokenJti(claims.jti)
044
const currentToken = await findRefreshTokenForSession({
045
db: db,
046
jtiHash,
047
sessionId: claims.sid,
048
})
049
050
if (!currentToken || currentToken.applicationCode !== 'admin') {
051
throw refreshTokenInvalidError()
052
}
053
054
if (currentToken.sessionRevokedAtMs !== null) {
055
throw sessionRevokedError()
056
}
057
058
if (currentToken.revokedAtMs !== null || currentToken.expiresAtMs <= nowMs) {
059
throw refreshTokenInvalidError()
060
}
061
062
if (currentToken.usedAtMs !== null) {
063
await revokeSession({
064
db: db,
065
sessionId: currentToken.sessionId,
066
revokedAtMs: nowMs,
067
reason: 'refresh_token_replay',
068
})
069
070
throw refreshTokenReplayedError()
071
}
072
073
const markedUsed = await markRefreshTokenUsed({
074
db: db,
075
tokenId: currentToken.tokenId,
076
usedAtMs: nowMs,
077
})
078
079
if (!markedUsed) {
080
await revokeSession({
081
db: db,
082
sessionId: currentToken.sessionId,
083
revokedAtMs: nowMs,
084
reason: 'refresh_token_replay',
085
})
086
087
throw refreshTokenReplayedError()
088
}
089
090
const roles = await getAdminRolesForUser(db, claims.sub)
091
092
if (roles.length === 0) {
093
await revokeSession({
094
db: db,
095
sessionId: currentToken.sessionId,
096
revokedAtMs: nowMs,
097
reason: 'admin_role_missing',
098
})
099
100
throw adminRoleRequiredError()
101
}
102
103
const refreshExpiresAtMs = nowMs + env.REFRESH_TOKEN_TTL_SEC * 1000
104
const session = {
105
sessionId: currentToken.sessionId,
106
userId: claims.sub,
107
app: 'admin' as const,
108
roles,
109
expiresAtMs: refreshExpiresAtMs,
110
}
111
112
const tokenPair = await issueAdminTokenPair({
113
session,
114
accessSecret: env.JWT_ACCESS_SECRET,
115
refreshSecret: env.JWT_REFRESH_SECRET,
116
accessTtlSec: env.ACCESS_TOKEN_TTL_SEC,
117
refreshTtlSec: env.REFRESH_TOKEN_TTL_SEC,
118
})
119
120
await insertRefreshToken({
121
db: db,
122
tokenId: tokenPair.refreshJti,
123
sessionId: currentToken.sessionId,
124
jtiHash: await hashTokenJti(tokenPair.refreshJti),
125
parentTokenId: currentToken.tokenId,
126
issuedAtMs: nowMs,
127
expiresAtMs: refreshExpiresAtMs,
128
})
129
130
await updateRefreshRotation({
131
db: db,
132
oldTokenId: currentToken.tokenId,
133
newTokenId: tokenPair.refreshJti,
134
sessionId: currentToken.sessionId,
135
lastSeenAtMs: nowMs,
136
})
137
138
return AdminTokenRefreshResponseSchema.parse({
139
accessToken: tokenPair.accessToken,
140
refreshToken: tokenPair.refreshToken,
141
tokenType: 'Bearer',
142
expiresInSec: env.ACCESS_TOKEN_TTL_SEC,
143
refreshExpiresInSec: env.REFRESH_TOKEN_TTL_SEC,
144
session,
145
})
146
}

3. 开头先把上下文准备好

和密码登录那条 service 很像,这段函数一开始也先把后面一定会反复用到的东西准备好:

admin-token-refresh.ts
1
const { c, payload } = params
2
const db = getDb(c.env.DB)
3
const env = getApiEnv(c.env)

这里没什么花活。

  • payload 里装的是前端传上来的 refresh token
  • db 是后面 repository 方法要用的数据库客户端
  • env 里装的是 refresh secret、access secret 和两类 token 的 TTL

这一步做完之后,后面整条 refresh 流程就能一直顺着往下走。

4. 第一层:先验这张 refresh token 本身对不对

真正的 refresh 流程,是从这里开始的:

admin-token-refresh.ts
01
let claims
02
03
try {
04
claims = await verifyRefreshToken({
05
token: payload.refreshToken,
06
secret: env.JWT_REFRESH_SECRET,
07
})
08
} catch {
09
throw refreshTokenInvalidError()
10
}

这里的意思很直接。

前端先把 refresh token 带上来,service 先交给 verifyRefreshToken(...) 去验。验的内容包括签名、过期时间、claims 结构这些基础合法性。

如果这一步就过不去,后面根本没必要再去查数据库了,直接收口成:

admin-token-refresh.ts
1
throw refreshTokenInvalidError()

也就是说,到这里为止,系统回答的问题只是:这张 token 在 JWT 这一层是不是一张像样的 refresh token。

5. 第二层:把 token 里的 jti 变成数据库能查的键

JWT 验通过之后,函数立刻做了两件事:

admin-token-refresh.ts
1
const nowMs = Date.now()
2
const jtiHash = await hashTokenJti(claims.jti)

nowMs 很好理解,后面一连串状态判断都要用当前时间。

jtiHash 更关键。

refresh token 里带着 jti,但后面查数据库时,不是直接拿原始 jti 去查,而是先做了一次 hash。这样数据库里真正匹配的是 hash 之后的值。

接着就把这个 hash 和 session id 一起拿去查当前 token 记录:

admin-token-refresh.ts
1
const currentToken = await findRefreshTokenForSession({
2
db: db,
3
jtiHash,
4
sessionId: claims.sid,
5
})

到这里,refresh 流程就从“验 JWT”进入了“验数据库状态”这一步。

6. 第三层:先确认数据库里真的有这张 token

查完数据库之后,第一道判断是:

admin-token-refresh.ts
1
if (!currentToken || currentToken.applicationCode !== 'admin') {
2
throw refreshTokenInvalidError()
3
}

这一步其实在做两件事。

第一件事,确认数据库里真的能找到这张 refresh token 的记录。

第二件事,确认它属于 admin 这条线,而不是别的 application。

只要其中一条不满足,还是直接收口成 refreshTokenInvalidError()

这说明 service 对外并不想暴露太细的内部状态差异。找不到、类型不对、application 不对,在这里都统一算无效 token。

7. 第四层:session 有没有被撤销

接下来单独看 session 状态:

admin-token-refresh.ts
1
if (currentToken.sessionRevokedAtMs !== null) {
2
throw sessionRevokedError()
3
}

这一句说明 refresh 不只是看 token 自己,还要看它挂在哪个 session 上。

如果整个 session 已经被撤销了,那这张 refresh token 就算自己没过期、没标记 used,也不该继续发新 token。

这里抛的是:

  • sessionRevokedError()

这和前面的 refreshTokenInvalidError() 分开了,说明“session 已撤销”在业务上被单独当成一类状态处理。

8. 第五层:token 自己有没有过期或被撤销

session 还活着,接着再看 token 自己:

admin-token-refresh.ts
1
if (currentToken.revokedAtMs !== null || currentToken.expiresAtMs <= nowMs) {
2
throw refreshTokenInvalidError()
3
}

这里看的是两件事:

  • revokedAtMs !== null
  • expiresAtMs <= nowMs

也就是这张 token 有没有被撤销,或者有没有过期。

这一步也很好理解。refresh token 本身如果已经失效,后面当然不能继续参与 rotation。

9. 第六层:如果它已经被用过,那事情就不是“普通失效”了

前面那些判断都还是“这张 token 是否还有效”。

到了这里,事情开始变得敏感:

admin-token-refresh.ts
01
if (currentToken.usedAtMs !== null) {
02
await revokeSession({
03
db: db,
04
sessionId: currentToken.sessionId,
05
revokedAtMs: nowMs,
06
reason: 'refresh_token_replay',
07
})
08
09
throw refreshTokenReplayedError()
10
}

usedAtMs !== null 的意思,就是这张 refresh token 之前已经成功用过一次了。

refresh token 一旦被设计成 rotation 模式,就不应该被重复使用。只要它已经被用过,还再次出现在这里,系统就会把它当成 replay 风险处理。

这里的处理也很干脆:

  • 先撤销整个 session
  • 再抛 refreshTokenReplayedError()

所以这一步和“过期了”“失效了”不一样。它不是单纯不给续签,而是把整条 session 直接收掉。

10. 第七层:真正的并发竞争点在 markRefreshTokenUsed

前面那一步拦住的是“已经明显被用过的 token”。

但并发刷新最麻烦的地方在于:两个请求可能几乎同时进来,在它们各自读 usedAtMs 时,看到的都还是 null

这时候真正决定谁赢谁输的,是这一步:

admin-token-refresh.ts
1
const markedUsed = await markRefreshTokenUsed({
2
db: db,
3
tokenId: currentToken.tokenId,
4
usedAtMs: nowMs,
5
})

它的作用,就是去抢这张旧 token 的“used 标记”。

后面的判断非常关键:

admin-token-refresh.ts
01
if (!markedUsed) {
02
await revokeSession({
03
db: db,
04
sessionId: currentToken.sessionId,
05
revokedAtMs: nowMs,
06
reason: 'refresh_token_replay',
07
})
08
09
throw refreshTokenReplayedError()
10
}

也就是说,只有第一个成功把旧 token 标成 used 的请求,才有资格继续往下走。

后面那些抢输的并发请求,不会继续发新 token,而是直接把整条 session 撤掉,再抛 replay 错误。

这一步其实就是整条 refresh 链里最容易写乱、也最关键的地方。

旧 token 一定要先抢占成功,再谈后面的新 token。顺序不能反。

11. 第八层:refresh 时还要重新查一次角色

并发问题处理完之后,函数又回头查了一次角色:

admin-token-refresh.ts
01
const roles = await getAdminRolesForUser(db, claims.sub)
02
03
if (roles.length === 0) {
04
await revokeSession({
05
db: db,
06
sessionId: currentToken.sessionId,
07
revokedAtMs: nowMs,
08
reason: 'admin_role_missing',
09
})
10
11
throw adminRoleRequiredError()
12
}

这一步非常有现实意义。

因为 refresh 不是单纯复刻旧 session,而是一次新的续签动作。既然是重新发 token,就顺手再看一遍这个人现在还有没有 admin 角色。

如果角色已经没了,系统不会继续给他续签,而是:

  • 先撤销 session
  • 再抛 adminRoleRequiredError()

这就把“后台权限变更”及时收进了下一次 refresh。

12. 旧 token 这边都处理完了,才开始准备新 session 视角的数据

到这里,旧 refresh token 该验证的都验证完了。接下来才轮到准备新 token 要用的数据:

admin-token-refresh.ts
1
const refreshExpiresAtMs = nowMs + env.REFRESH_TOKEN_TTL_SEC * 1000
2
const session = {
3
sessionId: currentToken.sessionId,
4
userId: claims.sub,
5
app: 'admin' as const,
6
roles,
7
expiresAtMs: refreshExpiresAtMs,
8
}

这里没有重新创建一个全新的 session,而是沿用原来的 sessionId

这很合理,因为 refresh 并不是重新登录,而是沿着当前 session 续签一轮新的 token。

不过新的 refresh 过期时间会重新按当前时间往后推,所以这里重新算了:

  • refreshExpiresAtMs

然后把当前续签这一轮需要的 session 视角数据整理出来,交给下面的 JWT 模块继续用。

13. 第九层:签一对新的 token

admin-token-refresh.ts
1
const tokenPair = await issueAdminTokenPair({
2
session,
3
accessSecret: env.JWT_ACCESS_SECRET,
4
refreshSecret: env.JWT_REFRESH_SECRET,
5
accessTtlSec: env.ACCESS_TOKEN_TTL_SEC,
6
refreshTtlSec: env.REFRESH_TOKEN_TTL_SEC,
7
})

这里和密码登录那条线很像,都是直接调:

  • issueAdminTokenPair(...)

也就是说,refresh 流程在真正发 token 这一层,并没有自己手写 JWT 细节,而是继续复用那套“给 admin session 发一对 token”的工具方法。

service 这一层只需要把:

  • session
  • secret
  • TTL

这些材料准备好,然后拿回新的 access token 和 refresh token。

14. 第十层:先把新 token 写进去

签完新 token 之后,先做的是插入新 token 的数据库记录:

admin-token-refresh.ts
1
await insertRefreshToken({
2
db: db,
3
tokenId: tokenPair.refreshJti,
4
sessionId: currentToken.sessionId,
5
jtiHash: await hashTokenJti(tokenPair.refreshJti),
6
parentTokenId: currentToken.tokenId,
7
issuedAtMs: nowMs,
8
expiresAtMs: refreshExpiresAtMs,
9
})

这里最值得看的,是:

  • tokenId: tokenPair.refreshJti
  • parentTokenId: currentToken.tokenId

这说明新 token 和旧 token 的关系已经被串起来了。

新 token 不是孤零零插进去的,它会明确指向自己的父 token,也就是这次被拿来续签的那一张旧 refresh token。

到这里为止,数据库里已经有了这张新 token 自己的状态记录。

15. 第十一层:再回头把旧 token 指向新 token

新 token 插进去之后,才轮到更新 rotation 关系:

admin-token-refresh.ts
1
await updateRefreshRotation({
2
db: db,
3
oldTokenId: currentToken.tokenId,
4
newTokenId: tokenPair.refreshJti,
5
sessionId: currentToken.sessionId,
6
lastSeenAtMs: nowMs,
7
})

这一步做的事,可以直接理解成两句:

  • 旧 token 现在已经被新 token 替换了
  • 这个 session 刚刚又活跃了一次

所以这一步不是“创建新 token”,而是把旧 token 和新 token 之间的交接关系补完整。

这也就是 refresh 这条线最核心的 rotation 写入顺序:

  • 先抢占旧 token 的 used 标记
  • 再签新 token
  • 再插入新 token 记录
  • 最后回写 old -> new 的替换关系

顺序乱了,整条 refresh 链就容易出问题。

16. 最后一步:把这一轮续签结果整理成响应

admin-token-refresh.ts
1
return AdminTokenRefreshResponseSchema.parse({
2
accessToken: tokenPair.accessToken,
3
refreshToken: tokenPair.refreshToken,
4
tokenType: 'Bearer',
5
expiresInSec: env.ACCESS_TOKEN_TTL_SEC,
6
refreshExpiresInSec: env.REFRESH_TOKEN_TTL_SEC,
7
session,
8
})

前面的工作都做完之后,最后一步才是把响应体整理好。

这里返回的内容和登录那条线很像:

  • 新 access token
  • 新 refresh token
  • token type
  • access token 过期秒数
  • refresh token 过期秒数
  • 当前 session 视角的数据

而且同样不是直接 return 普通对象,而是:

  • AdminTokenRefreshResponseSchema.parse(...)

这样响应结构就继续和契约层保持一致

NOTE

退出登录的逻辑,这里就不再文章中赘述,大家可以根据源码自行阅读学习