jwt.ts 是一组令牌工具方法我们先把 token 签发的功能单独封装出来
它只负责 4 件事:
jose 需要的格式所以读这份代码时,不要把注意力放在完整登录流程上,而是直接看每个方法收什么、做什么、返回什么。
001import { SignJWT, jwtVerify } from 'jose'002import { uuidv7 } from 'uuidv7'003import type {004AccessTokenClaims,005RefreshTokenClaims,006SessionContext,007} from './types'008009const encoder = new TextEncoder()010011function toSecret(secret: string): Uint8Array {012return encoder.encode(secret)013}014015export async function signAccessToken(params: {016claims: AccessTokenClaims017secret: string018ttlSec: number019}): Promise<string> {020const nowSec = Math.floor(Date.now() / 1000)021022// access token 只带请求链路真正需要的最小身份信息,避免把会话状态塞进无状态令牌里。023return new SignJWT({024sid: params.claims.sid,025app: params.claims.app,026roles: params.claims.roles,027})028.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })029.setSubject(params.claims.sub)030.setIssuedAt(nowSec)031.setExpirationTime(nowSec + params.ttlSec)032.sign(toSecret(params.secret))033}034035export async function signRefreshToken(params: {036claims: Omit<RefreshTokenClaims, 'jti'>037secret: string038ttlSec: number039}): Promise<{040token: string041jti: string042}> {043const nowSec = Math.floor(Date.now() / 1000)044const jti = uuidv7()045046// refresh token 额外携带 jti,是为了把每一次续签都变成可追踪、可撤销的一条状态记录。047const token = await new SignJWT({048sid: params.claims.sid,049app: params.claims.app,050jti,051})052.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })053.setSubject(params.claims.sub)054.setIssuedAt(nowSec)055.setExpirationTime(nowSec + params.ttlSec)056.sign(toSecret(params.secret))057058return { token, jti }059}060061export async function verifyRefreshToken(params: {062token: string063secret: string064}): Promise<RefreshTokenClaims> {065const { payload } = await jwtVerify(params.token, toSecret(params.secret), {066algorithms: ['HS256'],067})068069const sid = payload.sid070const app = payload.app071const jti = payload.jti072const sub = payload.sub073074if (075typeof sid !== 'string' ||076typeof app !== 'string' ||077typeof jti !== 'string' ||078typeof sub !== 'string' ||079app !== 'admin'080) {081throw new Error('Invalid refresh token claims')082}083084return {085sid,086app: 'admin',087jti,088sub,089}090}091092export async function issueAdminTokenPair(params: {093session: SessionContext094accessSecret: string095refreshSecret: string096accessTtlSec: number097refreshTtlSec: number098}): Promise<{099accessToken: string100refreshToken: string101refreshJti: string102}> {103const accessToken = await signAccessToken({104claims: {105sub: params.session.userId,106sid: params.session.sessionId,107app: params.session.app,108roles: params.session.roles,109},110secret: params.accessSecret,111ttlSec: params.accessTtlSec,112})113114const refresh = await signRefreshToken({115claims: {116sub: params.session.userId,117sid: params.session.sessionId,118app: params.session.app,119},120secret: params.refreshSecret,121ttlSec: params.refreshTtlSec,122})123124return {125accessToken,126refreshToken: refresh.token,127refreshJti: refresh.jti,128}129}
jose 里用到的两个核心 API这份代码虽然不长,但第一次看时,很多人会先被这一行卡住:
1import { SignJWT, jwtVerify } from 'jose'
这里先不要急着往业务逻辑里钻,先把 jose 这两个 API 的基本用法认清楚。
SignJWTSignJWT 是用来创建并签发 JWT 的。
它的调用方式是链式写法。也就是先 new 一个实例,把 payload 传进去,再一段一段补 header、subject、时间字段,最后调用 .sign(...)。
这份代码里的典型写法就是:
01new SignJWT({02sid: params.claims.sid,03app: params.claims.app,04roles: params.claims.roles,05})06.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })07.setSubject(params.claims.sub)08.setIssuedAt(nowSec)09.setExpirationTime(nowSec + params.ttlSec)10.sign(toSecret(params.secret))
可以把它顺着读成这样:
sub最后 .sign(...) 返回的就是一个 JWT 字符串。
jwtVerifyjwtVerify 则是反过来的动作,用来验证一张 JWT。
这份代码里的写法是:
1const { payload } = await jwtVerify(params.token, toSecret(params.secret), {2algorithms: ['HS256'],3})
它做的事可以直接按参数理解:
HS256如果校验通过,它会返回解析结果,这里只取了其中的 payload。
如果校验失败,比如签名不对、算法不匹配、token 已过期,这里就会直接抛错。
jose 的角色记清楚所以在这份 jwt.ts 里,jose 其实只做两类事:
SignJWT:负责把数据签成 JWTjwtVerify:负责把 JWT 验证并解析回来后面的 toSecret、signAccessToken、signRefreshToken、verifyRefreshToken,本质上都是在这两个基础 API 之上,再包一层更贴合当前项目的工具方法。
toSecret先看最小的方法:
1const encoder = new TextEncoder()23function toSecret(secret: string): Uint8Array {4return encoder.encode(secret)5}
它的输入很简单:一个字符串 secret。
它的输出也很简单:Uint8Array。
这里的作用就是做一次格式转换。因为传进来的 secret 通常来自环境变量,本质上是字符串,而 jose 在签名和验签时需要的是字节序列。
所以这个方法的意义,不在业务逻辑,而在于统一适配。
后面只要看到:
1.sign(toSecret(params.secret))
或者:
1jwtVerify(params.token, toSecret(params.secret), ...)
就知道它是在把字符串密钥转成 jose 能直接使用的格式。
signAccessToken01export async function signAccessToken(params: {02claims: AccessTokenClaims03secret: string04ttlSec: number05}): Promise<string> {06const nowSec = Math.floor(Date.now() / 1000)0708return new SignJWT({09sid: params.claims.sid,10app: params.claims.app,11roles: params.claims.roles,12})13.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })14.setSubject(params.claims.sub)15.setIssuedAt(nowSec)16.setExpirationTime(nowSec + params.ttlSec)17.sign(toSecret(params.secret))18}
这个方法签出来的是 access token。
先看它的入参:
claimssecretttlSecclaims 里是这张 token 想表达的身份信息。secret 是签名密钥。ttlSec 是过期时间,单位是秒。
再往下看方法内部。
nowSec1const nowSec = Math.floor(Date.now() / 1000)
这里把当前时间转成秒。
因为后面 .setIssuedAt(...) 和 .setExpirationTime(...) 用的都是秒级时间。
new SignJWT(...)1new SignJWT({2sid: params.claims.sid,3app: params.claims.app,4roles: params.claims.roles,5})
这里是在构造 JWT payload。
这份 access token 里放了 3 个自定义字段:
sidapproles这意味着后面接口在解析 access token 时,可以从 payload 里读到当前 session id、application 和角色信息。
.setSubject(...)1.setSubject(params.claims.sub)
这里单独设置了 sub。
也就是说,sub 没有放在前面的自定义 payload 对象里,而是作为 JWT 标准字段单独写入。
在这份代码里,sub 表示用户 id。
.setProtectedHeader(...)1.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
这一行是在设置 JWT header。
alg: 'HS256' 表示签名算法是 HS256typ: 'JWT' 表示这是一个 JWT这里不复杂,主要就是告诉验证方:这张 token 是按什么算法签出来的。
.setIssuedAt(...) 和 .setExpirationTime(...)1.setIssuedAt(nowSec)2.setExpirationTime(nowSec + params.ttlSec)
这两行分别写入:
过期时间的计算方式也很直接:当前秒数加上 ttlSec。
.sign(...)1.sign(toSecret(params.secret))
最后一步是真正签名。
这里把字符串 secret 先交给 toSecret(...) 转成 Uint8Array,再交给 jose 完成签名。
整个方法的返回值是一个 Promise<string>,也就是最终签好的 access token 字符串。
signRefreshToken01export async function signRefreshToken(params: {02claims: Omit<RefreshTokenClaims, 'jti'>03secret: string04ttlSec: number05}): Promise<{06token: string07jti: string08}> {09const nowSec = Math.floor(Date.now() / 1000)10const jti = uuidv7()1112const token = await new SignJWT({13sid: params.claims.sid,14app: params.claims.app,15jti,16})17.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })18.setSubject(params.claims.sub)19.setIssuedAt(nowSec)20.setExpirationTime(nowSec + params.ttlSec)21.sign(toSecret(params.secret))2223return { token, jti }24}
这个方法和 signAccessToken 很像,但有两个明显区别。
1claims: Omit<RefreshTokenClaims, 'jti'>
这里的 claims 不是完整的 RefreshTokenClaims,而是去掉了 jti。
这说明 jti 不是调用方传进来的,而是方法内部自己生成。
jti1const jti = uuidv7()
这一行会生成一个新的唯一 id。
后面构造 payload 时,除了 sid、app 之外,又额外放进了:
jti所以 refresh token 的 payload 里会多一个唯一编号。
1new SignJWT({2sid: params.claims.sid,3app: params.claims.app,4jti,5})6.setSubject(params.claims.sub)
这一段说明 refresh token 里最终会有这些关键字段:
subsidappjti和 access token 相比,它没有放 roles,但多了 jti。
1return { token, jti }
这个方法的返回值不是单独一个字符串,而是一个对象:
tokenjti这说明调用方后面不只需要 refresh token 本身,还需要知道这张 token 对应的唯一编号。
所以这个方法在职责上比 signAccessToken 多做了一件事:除了签 token,还把生成出来的编号一并交出去。
verifyRefreshToken01export async function verifyRefreshToken(params: {02token: string03secret: string04}): Promise<RefreshTokenClaims> {05const { payload } = await jwtVerify(params.token, toSecret(params.secret), {06algorithms: ['HS256'],07})0809const sid = payload.sid10const app = payload.app11const jti = payload.jti12const sub = payload.sub1314if (15typeof sid !== 'string' ||16typeof app !== 'string' ||17typeof jti !== 'string' ||18typeof sub !== 'string' ||19app !== 'admin'20) {21throw new Error('Invalid refresh token claims')22}2324return {25sid,26app: 'admin',27jti,28sub,29}30}
这个方法负责验证 refresh token,并把解析后的 claims 返回出去。
它只收两个值:
tokensecret一个是待校验的 refresh token,一个是验签密钥。
jwtVerify(...)1const { payload } = await jwtVerify(params.token, toSecret(params.secret), {2algorithms: ['HS256'],3})
这一步会做 JWT 校验。
这里有两个点值得直接看代码本身:
第一,它同样用了 toSecret(...),说明验签时也要把字符串密钥转成 Uint8Array。
第二,它把允许的算法明确限制成了:
HS256也就是说,这个方法只接受按 HS256 签出来的 token。
1const sid = payload.sid2const app = payload.app3const jti = payload.jti4const sub = payload.sub
这一段没有直接把 payload 整包返回,而是先把需要的字段一个个取出来。
从这里就能看出,这个方法真正关心的是 refresh token 里的 4 个字段:
sidappjtisub1if (2typeof sid !== 'string' ||3typeof app !== 'string' ||4typeof jti !== 'string' ||5typeof sub !== 'string' ||6app !== 'admin'7) {8throw new Error('Invalid refresh token claims')9}
这里做了两层检查。
一层是类型检查,要求这几个字段都必须是字符串。
另一层是值检查,要求:
app === 'admin'这说明这个验证方法不是泛用的 token 解析器,而是一个带有 admin 约束的 refresh token 验证方法。
1return {2sid,3app: 'admin',4jti,5sub,6}
校验通过后,它返回一个结构明确的对象。
这里没有把原始 payload 直接透传,而是返回一份已经整理过、已经通过类型和业务检查的结果。
issueAdminTokenPair01export async function issueAdminTokenPair(params: {02session: SessionContext03accessSecret: string04refreshSecret: string05accessTtlSec: number06refreshTtlSec: number07}): Promise<{08accessToken: string09refreshToken: string10refreshJti: string11}> {12const accessToken = await signAccessToken({13claims: {14sub: params.session.userId,15sid: params.session.sessionId,16app: params.session.app,17roles: params.session.roles,18},19secret: params.accessSecret,20ttlSec: params.accessTtlSec,21})2223const refresh = await signRefreshToken({24claims: {25sub: params.session.userId,26sid: params.session.sessionId,27app: params.session.app,28},29secret: params.refreshSecret,30ttlSec: params.refreshTtlSec,31})3233return {34accessToken,35refreshToken: refresh.token,36refreshJti: refresh.jti,37}38}
这个方法不是底层签名方法,而是一个组合方法。
它的作用很直接:一次性生成一对 token。
它不再直接收 claims,而是收一个更完整的 session:
sessionaccessSecretrefreshSecretaccessTtlSecrefreshTtlSec这意味着它不是“签单张 token”的方法,而是站在更上一层,用 session 去派生 access token 和 refresh token。
signAccessToken01const accessToken = await signAccessToken({02claims: {03sub: params.session.userId,04sid: params.session.sessionId,05app: params.session.app,06roles: params.session.roles,07},08secret: params.accessSecret,09ttlSec: params.accessTtlSec,10})
这里先把 session 里的字段映射成 access token 需要的 claims。
可以直接对照出来:
userId -> subsessionId -> sidapp -> approles -> rolessignRefreshToken1const refresh = await signRefreshToken({2claims: {3sub: params.session.userId,4sid: params.session.sessionId,5app: params.session.app,6},7secret: params.refreshSecret,8ttlSec: params.refreshTtlSec,9})
这里同样也是从 session 里取字段,但这次没有传 roles。
因为 refresh token 的签发方法不需要 roles,它只关心:
subsidapp1return {2accessToken,3refreshToken: refresh.token,4refreshJti: refresh.jti,5}
最后返回的是一个更适合业务层直接使用的结果:
accessTokenrefreshTokenrefreshJti这里把 signRefreshToken 返回的 token 重命名成了 refreshToken,把 jti 重命名成了 refreshJti,让返回值语义更清楚。
如果只看单个方法,容易把它们当成零散工具。
放在一起看,职责其实很清楚。
toSecret 负责做密钥格式转换。
signAccessToken 负责签 access token,返回字符串。
signRefreshToken 负责签 refresh token,同时返回 token 和它的 jti。
verifyRefreshToken 负责验 refresh token,再把需要的 claims 整理出来。
issueAdminTokenPair 不自己发明新规则,只是把前面的签发方法组装起来,提供一个更顺手的上层入口。
所以这份 `jwt.ts`` 更适合当成一个小型工具模块来看:底层方法负责单点动作,上层方法负责组合。