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
| API | Input/default | Return/behavior |
|---|---|---|
parseJwtPayload<T extends JwtPayload>(token) | Three dot-separated parts | T 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 seconds | true 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 string | readonly token/payload/earlyPeriod; isExpired computed against current clock |
JwtCompositeToken(token, earlyPeriod=0, sessionId=random) | CompositeToken | access and refresh JwtToken; isRefreshNeeded=access expired, isRefreshable=refresh valid, authenticated=access valid |
JwtCompositeTokenSerializer(earlyPeriod=0) | Expiry margin | serialize→JSON of raw tokens plus sessionId; deserialize→JwtCompositeToken using configured margin |
deserializeLegacy(value) | Older object containing token.accessToken and token.refreshToken strings | Rebuilds live class; throws TypeError for invalid legacy shape |
jwtCompositeTokenSerializer | Singleton, margin 0 | Default 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.
| Member | Result |
|---|---|
signIn(compositeToken) | void; sets a new JwtCompositeToken/session |
setCompositeToken(compositeToken) | Alias for signIn |
signOut() | void; removes stored token and emits inherited storage event |
authenticated | true only when stored access token is not expired |
currentUser | CoSecJwtPayload or null; null while unauthenticated |
get/set/remove/destroy/eventBus | Inherited 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 failure | Session | Error raised | onUnauthorized |
|---|---|---|---|
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 token | Token removed | RefreshTokenError(token, cause?) | Called |
| Anything else: network error, timeout, abort, 5xx, an error without a response | Token kept | RefreshUnavailableError(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.
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
