Skip to content

Token 与刷新 ​

Token 解析只进行本地解码与过期管理,不验证签名、issuer、audience 或权限;服务端仍是鉴权依据。

JWT 值与序列化 ​

API输入/默认值返回/行为
parseJwtPayload<T extends JwtPayload>(token)三段点号分隔字符串T 或 null;base64url/UTF-8 JSON payload 解码;解析错误记录日志并返回 null;payload 不是 JSON 对象(数字、字符串、数组、null)时返回 null;不验证 claim 形状
isTokenExpired(token, earlyPeriod=0)字符串或 CoSecJwtPayload;earlyPeriod 单位秒无法解析(含非对象 payload)、exp 非法/非有限值或 now >= exp-earlyPeriod 为 true;exp 缺失/null 不过期
JwtToken<Payload>(token, earlyPeriod=0)原始字符串readonly token/payload/earlyPeriod;isExpired 按当前时钟计算
JwtCompositeToken(token, earlyPeriod=0, sessionId=随机值)CompositeTokenaccess、refresh 为 JwtToken;isRefreshNeeded=access 过期,isRefreshable=refresh 有效,authenticated=access 有效
JwtCompositeTokenSerializer(earlyPeriod=0)提前过期量serialize→原始 token 加 sessionId 的 JSON;deserialize→使用指定提前量的 JwtCompositeToken
deserializeLegacy(value)含 token.accessToken 和 token.refreshToken 字符串的旧对象重建可用类实例;旧形状无效时抛 TypeError
jwtCompositeTokenSerializer单例,提前量 0默认独立序列化器

JwtPayload 声明必填 jti/sub/exp/iat,可选 iss/aud/nbf。CoSecJwtPayload 添加 tenantId、policies、roles、attributes。IJwtToken<Payload> 将 token/payload/isExpired 与 EarlyPeriodCapable 合并。RefreshTokenStatusCapable 提供 readonly isRefreshNeeded/isRefreshable。AccessToken、RefreshToken 各有一个字符串字段,CompositeToken 合并二者。

deserialize 的 JSON 解析错误会传播。新登录生成随机 sessionId;反序列化保留非空已存 sessionId,否则推导稳定旧协议标识。旧标识用于会话管理,不是密码学完整性验证。

TokenStorage ​

TokenStorage(options={}) 继承 KeyStorage<JwtCompositeToken>。选项为去掉 serializer 的部分 KeyStorageOptions 加 earlyPeriod。默认 key=DEFAULT_COSEC_TOKEN_KEY(cosec-token)、earlyPeriod=0、广播总线(serial delegate 名称取实际 key;由 destroy() 关闭,通过 eventBus 传入的总线保持打开)以及继承的环境存储。序列化器内部选择。共享事件总线的实例必须使用相同 earlyPeriod,否则构造抛错。

成员结果
signIn(compositeToken)void;设置新的 JwtCompositeToken/会话
setCompositeToken(compositeToken)signIn 别名
signOut()void;移除 token 并发出继承的存储事件
authenticated仅存储的 access token 未过期时为 true
currentUserCoSecJwtPayload 或 null;未鉴权时为 null
get/set/remove/destroy/eventBus继承 KeyStorage API 与清理所有权

过期本身不会安排事件或自动刷新,状态 getter 在读取时计算当前时间。不会创建后台刷新定时器。

刷新管理器与传输 ​

new JwtTokenManager(tokenStorage, tokenRefresher) 暴露两个依赖、currentToken(token/null)及状态 getter(无 token 时为 false)。refresh(exchange?): Promise<void> 在无 token 时拒绝并抛 Error('No token found')。同一 manager 对同一当前 token 的并发刷新共用 Promise。跨标签页时,若 token 存储使用浏览器的 localStorage(默认如此)且存在 Web Locks(navigator.locks),刷新在锁 cosec-refresh:<token 存储 key> 下进行:共享该存储的标签页依次刷新,等待过的标签页先重新读取存储,若另一标签页已为同一会话存入 token 就直接复用,而不再使用已用过的 refresh token;仅当存储中仍是自己的 token 时才刷新(会话已变或已退出登录则抛 RefreshSessionChangedError,不刷新)。没有 Web Locks、不在浏览器中(服务端一个进程在同一 key 下持有多个用户的存储),或存储不被各标签页共享时,各标签页独立刷新。同会话较新 token 优先于迟到的刷新结果。退出登录或切换会话会阻止陈旧回写,并抛出 RefreshSessionChangedError(cause?)。

刷新失败时,manager 用 tokenStorage.reload() 重新读取存储:如果另一个标签页已刷新同一会话(因此一次性 refresh token 在这里失败)并存入了新 token,就复用该 token,请求继续。刷新响应若缺少字符串类型的 accessToken 与 refreshToken,绝不存储。存储中仍是刷新失败的那个 token 时,后续处理取决于服务端是否拒绝了 refresh token:

刷新失败会话抛出的错误onUnauthorized
刷新端点返回 4xx(错误带有 exchange.response.status,刷新客户端拒绝时使用的 ExchangeError/HttpStatusValidationError 即如此),或响应不是 composite token删除 tokenRefreshTokenError(token, cause?)调用
其他情况:网络错误、超时、中止、5xx、没有响应的错误保留 tokenRefreshUnavailableError(token, cause?)不调用

自定义 TokenRefresher 以同样方式表示拒绝:用 exchange.response.status 为 4xx 的错误拒绝。抛出 RefreshUnavailableError 后用户仍保持登录,之后的请求会再次刷新。两个错误的 cause 都是刷新失败的原因;若刷新请求因其错误拦截器回调(如 onUnauthorized)抛错而失败,cause 是该请求的 ExchangeError,其 cause 才是回调错误。两个错误都暴露旧 JwtCompositeToken,日志应避免输出原始凭据。重试业务请求失败保持原错误传播,不移除已经刷新成功的凭据。finally 会清理待完成 Promise。未授权通知所有权与 exchange 错误处理器协调,不是通用事件队列。

TokenRefresher.refresh(token): Promise<CompositeToken> 是自定义传输契约。CoSecTokenRefresher({fetcher, endpoint}) 两字段必填,以 POST 发送 token 对象并提取 JSON。具体类的 refresh 还接受 shouldNotifyUnauthorized?: () => boolean。它设置 IGNORE_REFRESH_TOKEN_ATTRIBUTE_KEY 防止递归刷新;自定义传输若使用已配置 Fetcher,需要自行提供该属性。

完整内存示例 ​

示例仅在本地解码故意构造的模拟 token,不连接鉴权服务。它演示存储状态,不会签发有效凭据。

ts
import { TokenStorage } from '@ahoo-wang/fetcher-cosec';
import { InMemoryStorage } from '@ahoo-wang/fetcher-storage';
import { SerialTypedEventBus } from '@ahoo-wang/fetcher-eventbus';

const tokens = new TokenStorage({
  storage: new InMemoryStorage(),
  eventBus: new SerialTypedEventBus('example-token'),
});
const payload = {
  jti: 'demo',
  sub: 'user-1',
  iat: 0,
  exp: Math.floor(Date.now() / 1000) + 3600,
};
const jwt = `e30.${btoa(JSON.stringify(payload)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')}.demo`;
try {
  tokens.signIn({ accessToken: jwt, refreshToken: jwt });
  console.log(tokens.authenticated, tokens.currentUser?.sub);
  tokens.signOut();
} finally {
  tokens.destroy();
}

JwtPayload — packages/cosec/src/jwts.ts:17

CoSecJwtPayload — packages/cosec/src/jwts.ts:61

parseJwtPayload — packages/cosec/src/jwts.ts:91

EarlyPeriodCapable — packages/cosec/src/jwts.ts:132

isTokenExpired — packages/cosec/src/jwts.ts:155

IJwtToken — packages/cosec/src/jwtToken.ts:46

JwtToken — packages/cosec/src/jwtToken.ts:81

RefreshTokenStatusCapable — packages/cosec/src/jwtToken.ts:129

JwtCompositeToken — packages/cosec/src/jwtToken.ts:168

JwtCompositeTokenSerializer — packages/cosec/src/jwtToken.ts:257

jwtCompositeTokenSerializer — packages/cosec/src/jwtToken.ts:329

RefreshTokenError — packages/cosec/src/errors.ts:17

RefreshSessionChangedError — packages/cosec/src/errors.ts:28

RefreshUnavailableError — packages/cosec/src/errors.ts:42

JwtTokenManager — packages/cosec/src/jwtTokenManager.ts:74

DEFAULT_COSEC_TOKEN_KEY — packages/cosec/src/tokenStorage.ts:25

TokenStorageOptions — packages/cosec/src/tokenStorage.ts:46

TokenStorage — packages/cosec/src/tokenStorage.ts:56

AccessToken — packages/cosec/src/tokenRefresher.ts:30

RefreshToken — packages/cosec/src/tokenRefresher.ts:43

CompositeToken — packages/cosec/src/tokenRefresher.ts:60

TokenRefresher — packages/cosec/src/tokenRefresher.ts:76

CoSecTokenRefresherOptions — packages/cosec/src/tokenRefresher.ts:113

CoSecTokenRefresher — packages/cosec/src/tokenRefresher.ts:144

基于 Apache License 2.0 发布。