- 网络安全
- 认证鉴权
- 后端
【免费下载链接】jose
JWA, JWS, JWE, JWT, JWK, JWKS for Node.js, Browser, Cloudflare Workers, Deno, Bun, and other Web-interoperable runtimes
导读
UnsecuredJWT是 jose 库中用于处理Unsecured(无签名、无加密)JWT的工具类,这类 JWT 的受保护头部固定为{ "alg": "none" },通常用于纯声明传递、临时票据、内部调试或对安全要求极低的应用场景。本文将以官方文档 docs/jwt/unsecured/classes/UnsecuredJWT.md 为骨架,结合仓库源码 src/jwt/unsecured.ts 与测试用例,完整讲解该类从构造、声明设置、编码到解码验证的全流程,并深入剖析其底层校验逻辑、时间跨度解析规则与典型错误场景。读完本文,你将能够熟练使用UnsecuredJWT完成无签名 JWT 的生产与消费,并理解其与常规签名 JWT 在安全性上的本质差异。
一、UnsecuredJWT 是什么
根据 jose 官方 API 文档的定义,UnsecuredJWT类是一个用于处理{ "alg": "none" }Unsecured JWT 的实用工具。所谓 "Unsecured",即令牌既不签名也不加密:它的三段式紧凑序列中,第三段(签名段)为空字符串,整体结构为header.payload.(注意末尾的点号后没有任何签名内容)。
该类的定位可以从源码的模块注释中得到印证(见 src/jwt/unsecured.ts):
Unsecured (unsigned & unencrypted) JSON Web Tokens (JWT)
导出方式
UnsecuredJWT以命名导出的方式从主模块入口'jose'导出,同时也从子路径导出'jose/jwt/unsecured'提供。在仓库 src/index.ts 中可以看到这两行导出声明:
export { UnsecuredJWT } from './jwt/unsecured.js' export type { UnsecuredResult } from './jwt/unsecured.js'因此你可以用以下任一方式引入:
import { UnsecuredJWT } from 'jose' // 或 import { UnsecuredJWT } from 'jose/jwt/unsecured'适用场景与安全警告
无签名 JWT 意味着任何拿到令牌的人都可以随意篡改 payload 并重新编码,它不提供任何完整性保护。因此它只适合:
- 传递无需防篡改的公开声明(例如
urn:example:claim: true这样的只读标记); - 作为示例、教学或调试过程中的临时令牌;
- 下游系统已通过其他通道(如 TLS 之外的业务上下文)确认可信的场景。
切勿用它承载敏感数据、身份认证或授权决策。jose 官方文档将此类令牌明确定义为 "Unsecured",正是为了强调这一点。
二、编码(Encode):构造与声明链式设置
构造函数
new UnsecuredJWT(payload?)payload?:JWTPayload类型,即 JWT Claims Set 对象,默认值为空对象{}。
源码中,构造函数实际继承自JWTClaimsBuilder(见 src/jwt/unsecured.ts),其实现位于 src/lib/jwt_claims_set.ts:
export class JWTClaimsBuilder { constructor(payload: types.JWTPayload = {}) { if (!isObject(payload)) { throw new TypeError('JWT Claims Set MUST be an object') } ;(producerPayloads ||= new WeakMap()).set(this, structuredClone(payload)) } // ... }需要注意两点实现细节:
- 传入的 payload 必须是普通对象,否则抛出
TypeError('JWT Claims Set MUST be an object'); - payload 会通过
structuredClone进行深拷贝存入WeakMap,之后再调用链式 setter 修改的是副本,不会污染调用方传入的原始对象。
编码方法encode()
encode(): stringencode()将当前 Claims Set 编码为紧凑形式的 Unsecured JWT 字符串。源码(src/jwt/unsecured.ts)非常直白:
encode(): string { const header = b64u.encode(JSON.stringify({ alg: 'none' })) const payload = b64u.encode(jwtData(this)) return `${header}.${payload}.` }即:头部固定为{ "alg": "none" },与 payload 分别做 base64url 编码,用.拼接,最后签名段为空。jwtData(见 src/lib/jwt_claims_set.ts)在序列化前还会校验iat、nbf、exp三个时间声明若为数字则必须是有限数(Number.isFinite)。
完整的编码示例
官方文档给出的编码示例(已转换为从仓库根目录出发的完整上下文):
const unsecuredJwt = new jose.UnsecuredJWT({ 'urn:example:claim': true }) .setIssuedAt() .setIssuer('urn:example:issuer') .setAudience('urn:example:audience') .setExpirationTime('2h') .encode() console.log(unsecuredJwt)在测试 test/jwt/unsecured.test.ts 中,new UnsecuredJWT({ 'urn:example:claim': true }).encode()的产物被固定为:
eyJhbGciOiJub25lIn0.eyJ1cm46ZXhhbXBsZTpjbGFpbSI6dHJ1ZX0.解码这三段可以看到:头部为{"alg":"none"},payload 为{"urn:example:claim":true},签名段为空。另外,new UnsecuredJWT().encode()(空 payload)的输出是eyJhbGciOiJub25lIn0.e30.,其中e30.正是空对象{}的 base64url 编码(见 test/jwt/unsecured.test.ts)。
链式声明设置方法
以下 setter 均返回this,支持链式调用。它们定义在JWTClaimsBuilder基类中(src/lib/jwt_claims_set.ts),每个方法都会先做类型校验再写入 payload。
| 方法 | 对应 Claims | 参数类型 | 校验规则 |
|---|---|---|---|
setIssuer(issuer) | iss(Issuer) | string | 非字符串抛TypeError |
setSubject(subject) | sub(Subject) | string | 非字符串抛TypeError |
setAudience(audience) | aud(Audience) | string \| string[] | 必须是字符串或全为字符串的数组 |
setJti(jwtId) | jti(JWT ID) | string | 非字符串抛TypeError |
setIssuedAt(input?) | iat(Issued At) | string \| number \| Date,可省略 | 省略时取当前时间戳 |
setExpirationTime(input) | exp(Expiration Time) | string \| number \| Date | 见下文时间规则 |
setNotBefore(input) | nbf(Not Before) | string \| number \| Date | 见下文时间规则 |
对应的类型定义见JWTPayload:aud?、exp?、iat?、iss?、jti?、nbf?、sub?均为可选成员,且允许携带任意其他自定义成员。
时间声明(iat / exp / nbf)的输入规则
这是本类最值得细读的部分。三个时间 setter 接受三种输入(官方文档 UnsecuredJWT.md 有完整说明):
number:直接作为 Unix 时间戳(秒)使用;Date:转换为 Unix 时间戳(Math.floor(date.getTime() / 1000));string:解析为相对当前 Unix 时间戳的时间跨度。
其中字符串的解析实现位于 src/lib/jwt_claims_set.ts,核心正则与换算如下:
const REGEX = /^(\+|\-)? ?(\d+|\d+\.\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i const multipliers = { s: 1, m: 60, h: 3600, d: 86400, w: 604800, y: 31557600 }关键规则总结:
- 格式:数字后跟单位,例如
"5 minutes"、"1 day"; - 合法单位拼写:
sec/secs/second/seconds/s;minute/minutes/min/mins/m;hour/hours/hr/hrs/h;day/days/d;week/weeks/w;year/years/yr/yrs/y(正则忽略大小写,且只取首字符映射到乘数); - 月份不受支持;
- 一年按 365.25 天计算(乘数
y: 31557600= 365.25 × 86400); - 减法:前置
-(如-10s)或后缀"ago"(如"10s ago")都会让结果取负,即时间往过去偏移; - "from now" 后缀:
"10s from now"仅用于可读性,语义就是加到当前时间戳上;前置+(如+10s)同样合法; - 单位缺省(如
"10")或月份(month)会抛出TypeError('Invalid time period format')。
numericDate的统一换算逻辑(src/lib/jwt_claims_set.ts)为:
function numericDate(value: number | string | Date, label: string) { if (typeof value === 'number') return validateInput(label, value) if (value instanceof Date) return validateInput(label, epoch(value)) return epoch(new Date()) + secs(value) }即字符串输入最终都是当前Unix时间戳 + secs(字符串)。测试 test/jwt/time_setters.ts 覆盖了'10s'、'+10s'、'-10s'、'+ 10s'、'- 10s'、'10s from now'、'10s ago'、new Date(now * 1000)、数字0等多种输入向量,并通过 test/jwt/unsecured.test.ts 对setIssuer、setSubject、setAudience、setJti、setIssuedAt、setExpirationTime、setNotBefore七个方法逐一断言其写入的声明值与期望值一致。
setIssuedAt()还有一个特例:不传参数时直接使用当前时间戳(见 src/lib/jwt_claims_set.ts):
setIssuedAt(value?: number | string | Date): this { const payload = producerPayload(this) if (value === undefined) { payload.iat = epoch(new Date()) } else if (typeof value === 'string') { payload.iat = validateInput('setIssuedAt', epoch(new Date()) + secs(value)) } else { payload.iat = numericDate(value, 'setIssuedAt') } return this }三、解码(Decode):静态方法与 Claims 校验
静态方法签名
static decode<PayloadType = JWTPayload>(jwt: string, options?: JWTClaimVerificationOptions): UnsecuredResult<PayloadType>jwt:要解码的 Unsecured JWT 字符串;options?:JWTClaimVerificationOptions,JWT Claims Set 验证选项;- 泛型
PayloadType默认JWTPayload,用于声明你期望 payload 携带的类型; - 返回值:
UnsecuredResult,包含payload(JWT Claims Set)与header(JOSE 头部,对 Unsecured JWT 恒为{ "alg": "none" })。
官方文档的解码示例:
const { payload, header } = jose.UnsecuredJWT.decode(unsecuredJwt, { issuer: 'urn:example:issuer', audience: 'urn:example:audience', }) console.log(header) console.log(payload)解码底层实现与防御性校验
decode的实现位于 src/jwt/unsecured.ts,其校验链条非常严密,共分五步:
第一步:入参类型检查
if (typeof jwt !== 'string') { throw new JWTInvalid('Unsecured JWT must be a string') }第二步:紧凑结构检查
const { 0: encodedHeader, 1: encodedPayload, 2: signature, length } = jwt.split('.') if (length !== 3 || signature !== '') { throw new JWTInvalid('Invalid Unsecured JWT') }必须是三段且第三段为空字符串。测试中的'....'、'..'、'..foo'以及带签名段的'eyJhbGciOiJIUzI1NiJ9...'都会命中此分支(见 test/jwt/unsecured.test.ts)。
第三步:头部解析与 JWS 选项校验
头部经parseJoseHeader解析后,会依次执行validateCrit(crit 扩展参数识别)与validateB64(b64 参数校验)。任何JWSInvalid都会被包装为JWTInvalid('Invalid Unsecured JWT', { cause })抛出,因此调用方看到的是统一错误码ERR_JWT_INVALID,且可通过error.cause查看底层的ERR_JWS_INVALID详情。测试 test/jwt/unsecured.test.ts 覆盖了crit为 null、crit缺少对应扩展参数、b64类型错误等多种头部畸形场景。
第四步:算法与编码方式硬约束
if (header.alg !== 'none') { throw new JWTInvalid('Invalid Unsecured JWT') } if (!b64) { throw new JWTInvalid('JWTs MUST NOT use unencoded payload') }- 头部
alg必须是'none',否则拒绝解码(如测试中传入alg: 'HS256'的令牌); - 禁止使用
b64: false的未编码 payload 形式——这正是 RFC 7797); - 同时,带有未被识别的
crit扩展头参数的令牌会被拒绝并抛出ERR_JOSE_NOT_SUPPORTED(见 test/jwt/unsecured.test.ts)。
第五步:Claims Set 解析与选项验证
payload 段先 base64url 解码(失败会抛JWTInvalid('Failed to base64url decode the payload')),随后交给validateClaimsSet(src/lib/jwt_claims_set.ts)执行完整的 Claims 验证。payload 必须是顶层 JSON 对象,否则抛JWTInvalid('JWT Claims Set must be a top-level JSON object')。
验证选项详解(JWTClaimVerificationOptions)
decode的第二个参数即JWTClaimVerificationOptions,所有字段均为可选:
| 选项 | 类型 | 作用 |
|---|---|---|
issuer | string \| string[] | 期望的iss值;设置后强制要求iss声明存在 |
subject | string | 期望的sub值;设置后强制要求sub声明存在 |
audience | string \| string[] | 期望的aud值(可多个);设置后强制要求aud声明存在 |
requiredClaims | string[] | 必须存在的额外声明名列表 |
maxTokenAge | string \| number | 从iat起算的最大存活时间(秒或时间跨度字符串);设置后强制要求iat存在 |
clockTolerance | string \| number | 时钟偏移容忍(秒或时间跨度字符串),用于nbf、exp及maxTokenAge下的iat比较 |
currentDate | Date | 比较 NumericDate 时使用的"当前时间",默认new Date() |
typ | string | 期望的typ头部参数值;设置后强制要求头部携带typ |
底层实现要点(src/lib/jwt_claims_set.ts):
- 存在性检查:
requiredClaims加上由maxTokenAge/audience/subject/issuer选项隐式引入的iat/aud/sub/iss,构成必须存在的声明集合,缺失即抛JWTClaimValidationFailed(missing原因); - 值匹配:
iss支持数组任一匹配,aud支持"payload 的 aud 数组包含任一期望值"的包含式匹配(checkAudiencePresence见 src/lib/jwt_claims_set.ts),sub要求严格相等,不匹配抛check_failed原因; typ归一化:比较时会把typ小写化,并允许"JWT"与"application/JWT"互相等价(normalizeTyp,src/lib/jwt_claims_set.ts);nbf:nbf > now + tolerance时抛JWTClaimValidationFailed;exp:exp <= now - tolerance时抛JWTExpired(即ERR_JWT_EXPIRED);maxTokenAge:now - iat - tolerance > max抛JWTExpired(iat过旧);now - iat < -tolerance抛JWTClaimValidationFailed(iat在未来);- 三个时间声明(
iat/nbf/exp)若存在但不是数字,会以invalid原因抛JWTClaimValidationFailed。
相关错误类型都定义在 src/util/errors.ts:JWTInvalid(第 418 行起)、JWTExpired(第 196 行起)、JWTClaimValidationFailed(第 135 行起),它们统一继承自JOSEError基类,可通过error.code获取形如ERR_JWT_INVALID、ERR_JWT_EXPIRED、ERR_JWT_CLAIM_VALIDATION_FAILED的错误码,便于上层做分类处理。
类型化解码
由于decode是泛型方法,你可以为 payload 声明具体类型以获取完整的 TypeScript 推导:
interface MyClaims { 'urn:example:claim': boolean sub?: string } const { payload, header } = UnsecuredJWT.decode<MyClaims>(token, { issuer: 'urn:example:issuer', }) // payload 的类型为 MyClaims & JWTPayload解码结果UnsecuredResult<PayloadType>的完整结构见 docs/jwt/unsecured/interfaces/UnsecuredResult.md:payload为PayloadType & JWTPayload的交叉类型,header恒为JWSHeaderParameters(对 Unsecured JWT 总是{ "alg": "none" })。
四、测试覆盖:从测试用例反推行为契约
仓库在 test/jwt/unsecured.test.ts 中对本类做了系统性的行为验证,除前文已引用的场景外,还包括:
- 空 payload 编码:
new UnsecuredJWT().encode()产出eyJhbGciOiJub25lIn0.e30.; - 非法输入矩阵:
null、'....'、'..'、'..foo'、非none算法的令牌、非 base64url 的 payload 段,全部断言抛出ERR_JWT_INVALID及对应错误消息; - crit 与 b64 扩展:拒绝未知 crit 扩展、非法 crit 结构、非法 b64 类型;同时允许
b64: false/b64: true/b64: 'false'这些在非 JWT 上下文中合法、但在 Unsecured JWT 中会被安全处理的头部变体(注意:只要b64不为显式false即视为编码正常); - 七个链式 setter 的完整向量测试:通过 test/jwt/time_setters.ts 提供的输入矩阵逐一验证写入正确性。
这些测试证明了UnsecuredJWT的一个核心设计态度:对任何不符合 Unsecured JWT 严格定义的输入一律拒绝,绝不静默降级。
五、与其他模块的对照:何时不该使用 UnsecuredJWT
jose 仓库中与之形成对照的是签名与加密路径:
- 需要完整性保护时,应改用
CompactSign+compactVerify(或 Flattened / General 变体),并通过jwtVerify校验签名后消费 JWT; - 需要机密性保护时,应使用
EncryptJWT+jwtDecrypt; UnsecuredJWT只应在"无篡改风险"这一前提明确成立时使用。文档中反复出现的{ "alg": "none" }定义,正是 JOSE 规范对这类令牌的标准化称呼。
六、完整实战示例
将编码与解码串联起来,一个完整的可运行示例(Node.js / Deno / Bun 等支持 Web API 的环境均可):
import { UnsecuredJWT } from 'jose' // —— 编码 —— const token = new UnsecuredJWT({ 'urn:example:claim': true }) .setIssuedAt() .setIssuer('urn:example:issuer') .setAudience(['urn:example:audience-a', 'urn:example:audience-b']) .setExpirationTime('2h') .setNotBefore('-5 minutes') // 允许最多 5 分钟的签发时钟偏移 .setJti('550e8400-e29b-41d4-a716-446655440000') .encode() console.log(token) // header.payload.(无签名段) // —— 解码 + 校验 —— try { const { payload, header } = UnsecuredJWT.decode(token, { issuer: 'urn:example:issuer', audience: ['urn:example:audience-a', 'urn:example:audience-b'], clockTolerance: '5 minutes', // 容忍签发方与消费方之间的时钟偏移 }) console.log(header) // { alg: 'none' } console.log(payload) // 包含 iat / iss / aud / exp / nbf / jti 及自定义声明 } catch (err) { if (err.code === 'ERR_JWT_EXPIRED') { console.error('令牌已过期') } else if (err.code === 'ERR_JWT_CLAIM_VALIDATION_FAILED') { console.error('Claims 校验失败', err.claim, err.reason) } else { console.error('无效的 Unsecured JWT', err.code, err.message) } }七、小结与最佳实践
UnsecuredJWT将{ "alg": "none" }无签名 JWT 的编码与解码封装为一组类型安全、校验严密的 API。使用时的最佳实践建议:
- 明确安全边界:仅在无篡改风险或完整性由外部机制保障的场景使用;
- 充分利用验证选项:解码时传
issuer、audience、requiredClaims、maxTokenAge、clockTolerance等选项,把 Claims 校验交给 jose,而不是手工拼 JSON; - 善用错误码:基于
ERR_JWT_INVALID、ERR_JWT_EXPIRED、ERR_JWT_CLAIM_VALIDATION_FAILED等错误码做分类处理,并通过error.cause定位底层ERR_JWS_INVALID细节; - 时间跨度字符串要精确:牢记单位集合(秒/分/时/天/周/年)、
ago与-表示过去、年按 365.25 天换算,且不支持月。
如需深入了解相关类型与选项,可继续阅读仓库中的 docs/jwt/unsecured/interfaces/UnsecuredResult.md、docs/types/interfaces/JWTClaimVerificationOptions.md 与 docs/types/interfaces/JWTPayload.md;实现细节可对照 src/jwt/unsecured.ts 与 src/lib/jwt_claims_set.ts,测试契约可参考 test/jwt/unsecured.test.ts。
- 网络安全
- 认证鉴权
- 后端
【免费下载链接】jose
JWA, JWS, JWE, JWT, JWK, JWKS for Node.js, Browser, Cloudflare Workers, Deno, Bun, and other Web-interoperable runtimes
相关推荐
抖音批量下载器 douyin-downloader 存储层深度解析:SQLite 去重历史、异步文件管理与元数据落盘
抖音批量下载器 douyin downloader 存储层深度解析:SQLite 去重历史、异步文件管理与元数据落盘 本指南围绕 douyin download
网络安全认证鉴权后端TDengine 硬件故障排查手册:内存、硬盘、RAID 控制器与文件系统诊断实战
TDengine 硬件故障排查手册:内存、硬盘、RAID 控制器与文件系统诊断实战 适用场景:TDengine 数据库报出数据校验失败(如 block chec
网络安全认证鉴权后端Kimi Code CLI 插件系统实战:用 plugin.json 打造轻量级自定义工具
Kimi Code CLI 插件系统实战:用 plugin.json 打造轻量级自定义工具 Kimi Code CLI 的插件(Plugin)系统允许你通过一个
网络安全认证鉴权后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考