密码登录那条线,主线很清楚:查用户、验密码、建 session、发 token。
refresh 就不一样了。它看起来像只是“拿旧 token 换新 token”,真写起来却比登录更容易混乱。
因为这里不只是验一张 token 对不对,还得继续确认很多事:
所以读这段 service 时,脑子里最好一直盯着两件事:
第一,旧 refresh token 是怎么被一层层验下来的。
第二,旧 token 和新 token 之间的交接顺序是怎么写的。
001import { AdminTokenRefreshResponseSchema, type AdminTokenRefreshRequest } from '@repo/contracts'002import type { Context } from 'hono'003import type { ApiBindings } from '@/bindings'004import { getDb } from '@/db/client'005import { getApiEnv } from '@/env'006import {007adminRoleRequiredError,008refreshTokenInvalidError,009refreshTokenReplayedError,010sessionRevokedError,011} from '@/auth/errors'012import { issueAdminTokenPair, verifyRefreshToken } from '@/auth/jwt'013import {014findRefreshTokenForSession,015getAdminRolesForUser,016insertRefreshToken,017markRefreshTokenUsed,018revokeSession,019updateRefreshRotation,020} from '@/auth/repository'021import { hashTokenJti } from '@/auth/token-hash'022023export async function handleAdminTokenRefresh(params: {024c: Context<{ Bindings: ApiBindings }>025payload: AdminTokenRefreshRequest026}) {027const { c, payload } = params028const db = getDb(c.env.DB)029const env = getApiEnv(c.env)030031let claims032033try {034claims = await verifyRefreshToken({035token: payload.refreshToken,036secret: env.JWT_REFRESH_SECRET,037})038} catch {039throw refreshTokenInvalidError()040}041042const nowMs = Date.now()043const jtiHash = await hashTokenJti(claims.jti)044const currentToken = await findRefreshTokenForSession({045db: db,046jtiHash,047sessionId: claims.sid,048})049050if (!currentToken || currentToken.applicationCode !== 'admin') {051throw refreshTokenInvalidError()052}053054if (currentToken.sessionRevokedAtMs !== null) {055throw sessionRevokedError()056}057058if (currentToken.revokedAtMs !== null || currentToken.expiresAtMs <= nowMs) {059throw refreshTokenInvalidError()060}061062if (currentToken.usedAtMs !== null) {063await revokeSession({064db: db,065sessionId: currentToken.sessionId,066revokedAtMs: nowMs,067reason: 'refresh_token_replay',068})069070throw refreshTokenReplayedError()071}072073const markedUsed = await markRefreshTokenUsed({074db: db,075tokenId: currentToken.tokenId,076usedAtMs: nowMs,077})078079if (!markedUsed) {080await revokeSession({081db: db,082sessionId: currentToken.sessionId,083revokedAtMs: nowMs,084reason: 'refresh_token_replay',085})086087throw refreshTokenReplayedError()088}089090const roles = await getAdminRolesForUser(db, claims.sub)091092if (roles.length === 0) {093await revokeSession({094db: db,095sessionId: currentToken.sessionId,096revokedAtMs: nowMs,097reason: 'admin_role_missing',098})099100throw adminRoleRequiredError()101}102103const refreshExpiresAtMs = nowMs + env.REFRESH_TOKEN_TTL_SEC * 1000104const session = {105sessionId: currentToken.sessionId,106userId: claims.sub,107app: 'admin' as const,108roles,109expiresAtMs: refreshExpiresAtMs,110}111112const tokenPair = await issueAdminTokenPair({113session,114accessSecret: env.JWT_ACCESS_SECRET,115refreshSecret: env.JWT_REFRESH_SECRET,116accessTtlSec: env.ACCESS_TOKEN_TTL_SEC,117refreshTtlSec: env.REFRESH_TOKEN_TTL_SEC,118})119120await insertRefreshToken({121db: db,122tokenId: tokenPair.refreshJti,123sessionId: currentToken.sessionId,124jtiHash: await hashTokenJti(tokenPair.refreshJti),125parentTokenId: currentToken.tokenId,126issuedAtMs: nowMs,127expiresAtMs: refreshExpiresAtMs,128})129130await updateRefreshRotation({131db: db,132oldTokenId: currentToken.tokenId,133newTokenId: tokenPair.refreshJti,134sessionId: currentToken.sessionId,135lastSeenAtMs: nowMs,136})137138return AdminTokenRefreshResponseSchema.parse({139accessToken: tokenPair.accessToken,140refreshToken: tokenPair.refreshToken,141tokenType: 'Bearer',142expiresInSec: env.ACCESS_TOKEN_TTL_SEC,143refreshExpiresInSec: env.REFRESH_TOKEN_TTL_SEC,144session,145})146}
和密码登录那条 service 很像,这段函数一开始也先把后面一定会反复用到的东西准备好:
1const { c, payload } = params2const db = getDb(c.env.DB)3const env = getApiEnv(c.env)
这里没什么花活。
payload 里装的是前端传上来的 refresh tokendb 是后面 repository 方法要用的数据库客户端env 里装的是 refresh secret、access secret 和两类 token 的 TTL这一步做完之后,后面整条 refresh 流程就能一直顺着往下走。
真正的 refresh 流程,是从这里开始的:
01let claims0203try {04claims = await verifyRefreshToken({05token: payload.refreshToken,06secret: env.JWT_REFRESH_SECRET,07})08} catch {09throw refreshTokenInvalidError()10}
这里的意思很直接。
前端先把 refresh token 带上来,service 先交给 verifyRefreshToken(...) 去验。验的内容包括签名、过期时间、claims 结构这些基础合法性。
如果这一步就过不去,后面根本没必要再去查数据库了,直接收口成:
1throw refreshTokenInvalidError()
也就是说,到这里为止,系统回答的问题只是:这张 token 在 JWT 这一层是不是一张像样的 refresh token。
jti 变成数据库能查的键JWT 验通过之后,函数立刻做了两件事:
1const nowMs = Date.now()2const jtiHash = await hashTokenJti(claims.jti)
nowMs 很好理解,后面一连串状态判断都要用当前时间。
jtiHash 更关键。
refresh token 里带着 jti,但后面查数据库时,不是直接拿原始 jti 去查,而是先做了一次 hash。这样数据库里真正匹配的是 hash 之后的值。
接着就把这个 hash 和 session id 一起拿去查当前 token 记录:
1const currentToken = await findRefreshTokenForSession({2db: db,3jtiHash,4sessionId: claims.sid,5})
到这里,refresh 流程就从“验 JWT”进入了“验数据库状态”这一步。
查完数据库之后,第一道判断是:
1if (!currentToken || currentToken.applicationCode !== 'admin') {2throw refreshTokenInvalidError()3}
这一步其实在做两件事。
第一件事,确认数据库里真的能找到这张 refresh token 的记录。
第二件事,确认它属于 admin 这条线,而不是别的 application。
只要其中一条不满足,还是直接收口成 refreshTokenInvalidError()。
这说明 service 对外并不想暴露太细的内部状态差异。找不到、类型不对、application 不对,在这里都统一算无效 token。
接下来单独看 session 状态:
1if (currentToken.sessionRevokedAtMs !== null) {2throw sessionRevokedError()3}
这一句说明 refresh 不只是看 token 自己,还要看它挂在哪个 session 上。
如果整个 session 已经被撤销了,那这张 refresh token 就算自己没过期、没标记 used,也不该继续发新 token。
这里抛的是:
sessionRevokedError()这和前面的 refreshTokenInvalidError() 分开了,说明“session 已撤销”在业务上被单独当成一类状态处理。
session 还活着,接着再看 token 自己:
1if (currentToken.revokedAtMs !== null || currentToken.expiresAtMs <= nowMs) {2throw refreshTokenInvalidError()3}
这里看的是两件事:
revokedAtMs !== nullexpiresAtMs <= nowMs也就是这张 token 有没有被撤销,或者有没有过期。
这一步也很好理解。refresh token 本身如果已经失效,后面当然不能继续参与 rotation。
前面那些判断都还是“这张 token 是否还有效”。
到了这里,事情开始变得敏感:
01if (currentToken.usedAtMs !== null) {02await revokeSession({03db: db,04sessionId: currentToken.sessionId,05revokedAtMs: nowMs,06reason: 'refresh_token_replay',07})0809throw refreshTokenReplayedError()10}
usedAtMs !== null 的意思,就是这张 refresh token 之前已经成功用过一次了。
refresh token 一旦被设计成 rotation 模式,就不应该被重复使用。只要它已经被用过,还再次出现在这里,系统就会把它当成 replay 风险处理。
这里的处理也很干脆:
refreshTokenReplayedError()所以这一步和“过期了”“失效了”不一样。它不是单纯不给续签,而是把整条 session 直接收掉。
markRefreshTokenUsed前面那一步拦住的是“已经明显被用过的 token”。
但并发刷新最麻烦的地方在于:两个请求可能几乎同时进来,在它们各自读 usedAtMs 时,看到的都还是 null。
这时候真正决定谁赢谁输的,是这一步:
1const markedUsed = await markRefreshTokenUsed({2db: db,3tokenId: currentToken.tokenId,4usedAtMs: nowMs,5})
它的作用,就是去抢这张旧 token 的“used 标记”。
后面的判断非常关键:
01if (!markedUsed) {02await revokeSession({03db: db,04sessionId: currentToken.sessionId,05revokedAtMs: nowMs,06reason: 'refresh_token_replay',07})0809throw refreshTokenReplayedError()10}
也就是说,只有第一个成功把旧 token 标成 used 的请求,才有资格继续往下走。
后面那些抢输的并发请求,不会继续发新 token,而是直接把整条 session 撤掉,再抛 replay 错误。
这一步其实就是整条 refresh 链里最容易写乱、也最关键的地方。
旧 token 一定要先抢占成功,再谈后面的新 token。顺序不能反。
并发问题处理完之后,函数又回头查了一次角色:
01const roles = await getAdminRolesForUser(db, claims.sub)0203if (roles.length === 0) {04await revokeSession({05db: db,06sessionId: currentToken.sessionId,07revokedAtMs: nowMs,08reason: 'admin_role_missing',09})1011throw adminRoleRequiredError()12}
这一步非常有现实意义。
因为 refresh 不是单纯复刻旧 session,而是一次新的续签动作。既然是重新发 token,就顺手再看一遍这个人现在还有没有 admin 角色。
如果角色已经没了,系统不会继续给他续签,而是:
adminRoleRequiredError()这就把“后台权限变更”及时收进了下一次 refresh。
到这里,旧 refresh token 该验证的都验证完了。接下来才轮到准备新 token 要用的数据:
1const refreshExpiresAtMs = nowMs + env.REFRESH_TOKEN_TTL_SEC * 10002const session = {3sessionId: currentToken.sessionId,4userId: claims.sub,5app: 'admin' as const,6roles,7expiresAtMs: refreshExpiresAtMs,8}
这里没有重新创建一个全新的 session,而是沿用原来的 sessionId。
这很合理,因为 refresh 并不是重新登录,而是沿着当前 session 续签一轮新的 token。
不过新的 refresh 过期时间会重新按当前时间往后推,所以这里重新算了:
refreshExpiresAtMs然后把当前续签这一轮需要的 session 视角数据整理出来,交给下面的 JWT 模块继续用。
1const tokenPair = await issueAdminTokenPair({2session,3accessSecret: env.JWT_ACCESS_SECRET,4refreshSecret: env.JWT_REFRESH_SECRET,5accessTtlSec: env.ACCESS_TOKEN_TTL_SEC,6refreshTtlSec: env.REFRESH_TOKEN_TTL_SEC,7})
这里和密码登录那条线很像,都是直接调:
issueAdminTokenPair(...)也就是说,refresh 流程在真正发 token 这一层,并没有自己手写 JWT 细节,而是继续复用那套“给 admin session 发一对 token”的工具方法。
service 这一层只需要把:
这些材料准备好,然后拿回新的 access token 和 refresh token。
签完新 token 之后,先做的是插入新 token 的数据库记录:
1await insertRefreshToken({2db: db,3tokenId: tokenPair.refreshJti,4sessionId: currentToken.sessionId,5jtiHash: await hashTokenJti(tokenPair.refreshJti),6parentTokenId: currentToken.tokenId,7issuedAtMs: nowMs,8expiresAtMs: refreshExpiresAtMs,9})
这里最值得看的,是:
tokenId: tokenPair.refreshJtiparentTokenId: currentToken.tokenId这说明新 token 和旧 token 的关系已经被串起来了。
新 token 不是孤零零插进去的,它会明确指向自己的父 token,也就是这次被拿来续签的那一张旧 refresh token。
到这里为止,数据库里已经有了这张新 token 自己的状态记录。
新 token 插进去之后,才轮到更新 rotation 关系:
1await updateRefreshRotation({2db: db,3oldTokenId: currentToken.tokenId,4newTokenId: tokenPair.refreshJti,5sessionId: currentToken.sessionId,6lastSeenAtMs: nowMs,7})
这一步做的事,可以直接理解成两句:
所以这一步不是“创建新 token”,而是把旧 token 和新 token 之间的交接关系补完整。
这也就是 refresh 这条线最核心的 rotation 写入顺序:
顺序乱了,整条 refresh 链就容易出问题。
1return AdminTokenRefreshResponseSchema.parse({2accessToken: tokenPair.accessToken,3refreshToken: tokenPair.refreshToken,4tokenType: 'Bearer',5expiresInSec: env.ACCESS_TOKEN_TTL_SEC,6refreshExpiresInSec: env.REFRESH_TOKEN_TTL_SEC,7session,8})
前面的工作都做完之后,最后一步才是把响应体整理好。
这里返回的内容和登录那条线很像:
而且同样不是直接 return 普通对象,而是:
AdminTokenRefreshResponseSchema.parse(...)这样响应结构就继续和契约层保持一致
退出登录的逻辑,这里就不再文章中赘述,大家可以根据源码自行阅读学习