Skip to content

Tokens and refresh ​

Token parsing is local decoding and expiration bookkeeping. It does not verify signatures, issuer, audience or permissions; the service remains the authority for authentication.

JWT values and serialization ​

APIInput/defaultReturn/behavior
parseJwtPayload<T extends JwtPayload>(token)Three dot-separated partsT or null; base64url/UTF-8 JSON payload decoding; parse errors logged and return null; a payload that is not a JSON object (number, string, array, null) returns null; no claim-shape validation
isTokenExpired(token, earlyPeriod=0)String or CoSecJwtPayload; earlyPeriod in secondstrue for unparseable token (including a non-object payload), invalid/nonfinite exp, or now >= exp-earlyPeriod; absent/null exp is not expired
JwtToken<Payload>(token, earlyPeriod=0)Raw stringreadonly token/payload/earlyPeriod; isExpired computed against current clock
JwtCompositeToken(token, earlyPeriod=0, sessionId=random)CompositeTokenaccess and refresh JwtToken; isRefreshNeeded=access expired, isRefreshable=refresh valid, authenticated=access valid
JwtCompositeTokenSerializer(earlyPeriod=0)Expiry marginserialize→JSON of raw tokens plus sessionId; deserialize→JwtCompositeToken using configured margin
deserializeLegacy(value)Older object containing token.accessToken and token.refreshToken stringsRebuilds live class; throws TypeError for invalid legacy shape
jwtCompositeTokenSerializerSingleton, margin 0Default standalone serializer

JwtPayload declares required jti/sub/exp/iat and optional iss/aud/nbf. CoSecJwtPayload adds tenantId, policies, roles, attributes. IJwtToken<Payload> combines token/payload/isExpired with EarlyPeriodCapable. RefreshTokenStatusCapable supplies readonly isRefreshNeeded/isRefreshable. AccessToken and RefreshToken are one-string-field shapes; CompositeToken combines them.

JSON parse failures from deserialize propagate. New sign-ins receive a random sessionId; deserialization preserves a nonempty stored sessionId and derives a stable legacy identifier otherwise. The legacy identifier is session bookkeeping, not a cryptographic integrity check.

TokenStorage ​

TokenStorage(options={}) extends KeyStorage<JwtCompositeToken>. Options are partial KeyStorageOptions excluding serializer, plus earlyPeriod. Defaults: key=DEFAULT_COSEC_TOKEN_KEY (cosec-token), earlyPeriod=0, a broadcast bus with serial delegate named for the actual key (closed by destroy(); a bus passed in eventBus stays open), and inherited environment storage. Serialization is selected internally. Instances sharing an event bus must use the same earlyPeriod or construction throws.

MemberResult
signIn(compositeToken)void; sets a new JwtCompositeToken/session
setCompositeToken(compositeToken)Alias for signIn
signOut()void; removes stored token and emits inherited storage event
authenticatedtrue only when stored access token is not expired
currentUserCoSecJwtPayload or null; null while unauthenticated
get/set/remove/destroy/eventBusInherited KeyStorage API and cleanup ownership

Expiration alone does not schedule events or automatically refresh; status getters evaluate time when read. No background refresh timer is created.

Refresh manager and transport ​

new JwtTokenManager(tokenStorage, tokenRefresher) exposes both dependencies, currentToken (token/null), and status getters (false with no token). refresh(exchange?): Promise<void> rejects with Error('No token found') without a token. Concurrent refreshes for the same current token on this manager share a promise. Across tabs, for a token storage on the browser's localStorage (the default) and where Web Locks (navigator.locks) exist, the refresh runs under the lock cosec-refresh:<token storage key>: tabs sharing that storage refresh one at a time, and a tab that waited re-reads storage first and reuses a token another tab stored for the same session instead of spending the already used refresh token; it refreshes only when storage still holds its own token (another session or a sign-out raises RefreshSessionChangedError without refreshing). Without Web Locks, outside a browser (a server process holds many users' storages under one key), or on a storage tabs do not share, tabs refresh independently. A same-session newer token wins over late refresh results. Sign-out or a different session prevents stale writeback and raises RefreshSessionChangedError(cause?).

When a refresh fails, the manager re-reads storage with tokenStorage.reload(): if another tab has already refreshed the same session (so a one-time refresh token failed here) and stored its token, that token is reused and the request continues. A refresh response without string accessToken and refreshToken is never stored. When storage still holds the token that failed to refresh, what happens next depends on whether the server rejected the refresh token:

Refresh failureSessionError raisedonUnauthorized
The refresh endpoint answered 4xx (the error carries exchange.response.status, as the ExchangeError/HttpStatusValidationError the refresh client rejects with does), or the response is not a composite tokenToken removedRefreshTokenError(token, cause?)Called
Anything else: network error, timeout, abort, 5xx, an error without a responseToken keptRefreshUnavailableError(token, cause?)Not called

A custom TokenRefresher signals a rejection the same way: reject with an error whose exchange.response.status is the 4xx. After RefreshUnavailableError the user stays signed in and a later request refreshes again. In both errors cause is the refresh failure; when the refresh request failed because one of its error-interceptor callbacks (such as onUnauthorized) threw, cause is that request's ExchangeError, whose own cause is the callback error. Both errors expose the old JwtCompositeToken; avoid logging its raw credentials. A failure of the retried business request propagates unchanged and does not remove successfully refreshed credentials. The pending promise is cleared in finally. Unauthorized notification ownership is coordinated with exchange error handlers, not a general event queue.

TokenRefresher.refresh(token): Promise<CompositeToken> is the custom transport contract. CoSecTokenRefresher({fetcher, endpoint}) requires both fields and POSTs the token object with JSON result extraction. Its concrete refresh method additionally accepts shouldNotifyUnauthorized?: () => boolean. It sets IGNORE_REFRESH_TOKEN_ATTRIBUTE_KEY to prevent recursive refresh; custom transports using a configured Fetcher must supply that attribute themselves.

Complete in-memory example ​

This example decodes deliberately synthetic tokens locally and never contacts an authentication service. It demonstrates storage status; it does not mint valid credentials.

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

Released under the Apache License 2.0.