ts-retoken
v0.2.0
Published
Lightweight TypeScript library for automatic JWT token refresh with cross-tab synchronization
Maintainers
Readme
ts-retoken
A lightweight, type-safe token refresh library for JavaScript/TypeScript applications. Zero dependencies, works with any frontend framework.
Features
- Type-safe: Full TypeScript support with generics for API response types
- Two storage modes: localStorage or HTTP-only cookie
- Configurable: Custom status codes, retry delays, and response parsing
- Proactive refresh: Refreshes tokens before they expire
- Request deduplication: Only one refresh request at a time
- Retry with backoff: Exponential backoff for failed refresh requests
- Cross-tab sync: Optional logout synchronization across browser tabs
- Zero dependencies: Uses native
fetchAPI
Installation
npm install ts-retokenQuick Start
import { createRetoken } from 'ts-retoken';
const retoken = createRetoken({
refreshEndpoint: {
url: 'https://api.example.com/auth/refresh',
parseResponse: (data) => ({
accessToken: data.access_token,
refreshToken: data.refresh_token,
}),
},
getAccessToken: () => localStorage.getItem('access_token'),
getRefreshToken: () => localStorage.getItem('refresh_token'),
setTokens: (tokens) => {
localStorage.setItem('access_token', tokens.accessToken);
localStorage.setItem('refresh_token', tokens.refreshToken);
},
clearTokens: () => localStorage.clear(),
onAuthFailure: () => {
window.location.href = '/login';
},
});
// Use the fetch wrapper - handles token refresh automatically
const response = await retoken.fetch('/api/users/me');
const user = await response.json();Storage Modes
Mode 1: localStorage (or any storage)
Provide getRefreshToken to use localStorage, sessionStorage, or any custom storage:
const retoken = createRetoken({
refreshEndpoint: {
url: '/api/auth/refresh',
buildBody: (token) => JSON.stringify({ refresh_token: token }),
parseResponse: (data) => ({
accessToken: data.access_token,
refreshToken: data.refresh_token,
}),
},
getAccessToken: () => localStorage.getItem('access_token'),
getRefreshToken: () => localStorage.getItem('refresh_token'),
setTokens: (tokens) => {
localStorage.setItem('access_token', tokens.accessToken);
localStorage.setItem('refresh_token', tokens.refreshToken);
},
clearTokens: () => localStorage.clear(),
});Mode 2: HTTP-only Cookie
Omit getRefreshToken for HTTP-only cookie mode. The refresh token is sent automatically via cookies:
const retoken = createRetoken({
refreshEndpoint: {
url: '/api/auth/refresh',
credentials: 'include', // Send cookies with request
parseResponse: (data) => ({
accessToken: data.access_token,
refreshToken: '', // Not needed in cookie mode
}),
},
getAccessToken: () => localStorage.getItem('access_token'),
// getRefreshToken OMITTED = cookie mode
setTokens: (tokens) => {
localStorage.setItem('access_token', tokens.accessToken);
},
clearTokens: () => localStorage.removeItem('access_token'),
});Type-Safe API Responses
Use generics to get full TypeScript inference for your API response:
// Define your API response type
interface RefreshResponse {
data: {
access_token: string;
refresh_token: string;
expires_in: number;
};
}
// Pass it as a generic parameter
const retoken = createRetoken<RefreshResponse>({
refreshEndpoint: {
url: '/api/auth/refresh',
parseResponse: (data) => ({
// 'data' is typed as RefreshResponse
// TypeScript will autocomplete: data.data.access_token
accessToken: data.data.access_token,
refreshToken: data.data.refresh_token,
}),
},
// ... other config
});API Reference
createRetoken<TResponse>(config)
Creates a retoken instance with the provided configuration.
Config Options
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| refreshEndpoint | RefreshEndpointConfig | Yes | - | Refresh endpoint configuration |
| getAccessToken | () => string \| null | Yes | - | Function to get current access token |
| getRefreshToken | () => string \| null | No | - | Function to get refresh token (omit for cookie mode) |
| setTokens | (tokens: TokenPair) => void | Yes | - | Function to store new tokens |
| clearTokens | () => void | Yes | - | Function to clear tokens on auth failure |
| expirationLeeway | number | No | 60 | Seconds before expiration to refresh proactively |
| retryStatuses | number[] | No | [401] | Status codes that trigger refresh + retry |
| refreshFailureStatuses | number[] | No | [401, 403] | Refresh status codes that mean auth failed |
| retry | RetryConfig | No | See below | Retry configuration |
| crossTab | CrossTabConfig | No | { enabled: false } | Cross-tab sync configuration |
| onAuthFailure | () => void | No | - | Callback when auth fails completely |
| onTokenRefresh | (tokens: TokenPair) => void | No | - | Callback when tokens are refreshed |
RefreshEndpointConfig
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| url | string | Yes | - | Full URL to refresh endpoint |
| method | 'POST' \| 'PUT' | No | 'POST' | HTTP method |
| credentials | RequestCredentials | No | 'same-origin' | Fetch credentials mode |
| headers | Record<string, string> | No | - | Additional headers |
| buildBody | (token: string) => BodyInit | No | JSON with refresh_token | Build request body |
| parseResponse | (response: TResponse) => TokenPair | Yes | - | Parse response to TokenPair |
RetryConfig
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| delays | number[] | [3000, 6000, 12000] | Delays between retries (ms) |
| skipOnClientError | boolean | true | Skip retry on 4xx errors |
CrossTabConfig
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| enabled | boolean | false | Enable cross-tab sync |
| channelName | string | 'ts-retoken-auth' | BroadcastChannel name |
RetokenInstance
The object returned by createRetoken():
| Method | Type | Description |
|--------|------|-------------|
| fetch | (url: string, options?: RetokenFetchOptions) => Promise<Response> | Fetch wrapper with auto-refresh |
| fetchJson | <T>(url: string, options?: RetokenFetchJsonOptions) => Promise<T> | Type-safe fetch that returns parsed JSON |
| refreshToken | () => Promise<TokenPair> | Manually trigger token refresh |
| isTokenExpiringSoon | () => boolean | Check if access token expires soon |
| parseTokenExpiration | (token: string) => number \| null | Parse JWT expiration (ms) |
| broadcastLogout | () => void | Broadcast logout to other tabs |
| destroy | () => void | Cleanup resources |
RetokenFetchOptions
Options for the fetch wrapper (extends RequestInit):
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| headers | Record<string, string> | - | Request headers |
| skipProactiveRefresh | boolean | false | Skip proactive token refresh |
| skipRetry | boolean | false | Skip retry on retryStatuses |
RetokenFetchJsonOptions
Options for the fetchJson wrapper (extends RetokenFetchOptions):
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| expectedStatuses | number[] | [200, 201] | HTTP status codes that indicate success |
fetchJson<T>(url, options)
Type-safe fetch wrapper that returns parsed JSON with automatic token management.
Features:
- Proactively refreshes token if expiring soon (unless
skipProactiveRefreshis true) - Retries with a new token on 401 responses (unless
skipRetryis true) - Parses response body as JSON with full type safety
- Throws
FetchErrorfor unexpected HTTP status codes - Returns
nullfor 204 No Content responses
Basic Usage:
interface User {
id: string;
name: string;
email: string;
}
// GET request - response is typed as User
const user = await retoken.fetchJson<User>('/api/users/me');
console.log(user.name); // Fully typedPOST Request with Custom Status Codes:
interface CreateUserResponse {
id: string;
createdAt: string;
}
const newUser = await retoken.fetchJson<CreateUserResponse>('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'John', email: '[email protected]' }),
expectedStatuses: [201], // Only 201 is considered success
});Error Handling:
import { FetchError } from 'ts-retoken';
try {
const data = await retoken.fetchJson<SomeType>('/api/resource');
} catch (error) {
if (error instanceof FetchError) {
console.log(error.message); // "Request failed with status 404"
console.log(error.status); // 404
console.log(error.body); // Parsed error response body (if JSON)
}
}FetchError Properties:
| Property | Type | Description |
|----------|------|-------------|
| message | string | Error message including status code |
| status | number | HTTP status code |
| body | unknown | Parsed response body (if JSON) or null |
Standalone Utilities
import { isTokenExpiringSoon, parseTokenExpiration, RefreshError } from 'ts-retoken';
// Check if token expires within 60 seconds
const expiring = isTokenExpiringSoon(token, 60);
// Parse expiration timestamp from JWT
const expiresAt = parseTokenExpiration(token); // milliseconds or null
// RefreshError has a status property
try {
await retoken.refreshToken();
} catch (error) {
if (error instanceof RefreshError) {
console.log('Refresh failed with status:', error.status);
}
}Advanced Examples
With React
// lib/auth.ts
import { createRetoken, TokenPair } from 'ts-retoken';
// Mutable callback holder for React hooks integration
export const authCallbacks = {
onAuthFailure: () => {},
onTokenRefresh: (_tokens: TokenPair) => {},
};
export const retoken = createRetoken({
refreshEndpoint: {
url: `${import.meta.env.VITE_API_URL}/auth/refresh`,
parseResponse: (data) => ({
accessToken: data.access_token,
refreshToken: data.refresh_token,
}),
},
getAccessToken: () => localStorage.getItem('access_token'),
getRefreshToken: () => localStorage.getItem('refresh_token'),
setTokens: (tokens) => {
localStorage.setItem('access_token', tokens.accessToken);
localStorage.setItem('refresh_token', tokens.refreshToken);
},
clearTokens: () => localStorage.clear(),
crossTab: { enabled: true },
// Delegate to mutable callbacks
onAuthFailure: () => authCallbacks.onAuthFailure(),
onTokenRefresh: (tokens) => authCallbacks.onTokenRefresh(tokens),
});// AuthProvider.tsx - Set callbacks with React hooks
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { authCallbacks } from './auth';
export function AuthProvider({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
useEffect(() => {
authCallbacks.onAuthFailure = () => {
navigate('/login');
};
return () => {
authCallbacks.onAuthFailure = () => {};
};
}, [navigate]);
return <>{children}</>;
}// Use in components
const users = await retoken.fetch('/api/users').then(r => r.json());With Vue
// composables/useAuth.ts
import { createRetoken } from 'ts-retoken';
import { ref, onUnmounted } from 'vue';
const accessToken = ref<string | null>(localStorage.getItem('access_token'));
export const retoken = createRetoken({
refreshEndpoint: {
url: '/api/auth/refresh',
parseResponse: (data) => ({
accessToken: data.access_token,
refreshToken: data.refresh_token,
}),
},
getAccessToken: () => accessToken.value,
getRefreshToken: () => localStorage.getItem('refresh_token'),
setTokens: (tokens) => {
accessToken.value = tokens.accessToken;
localStorage.setItem('access_token', tokens.accessToken);
localStorage.setItem('refresh_token', tokens.refreshToken);
},
clearTokens: () => {
accessToken.value = null;
localStorage.clear();
},
});
export function useAuth() {
onUnmounted(() => retoken.destroy());
return { fetch: retoken.fetch, isTokenExpiringSoon: retoken.isTokenExpiringSoon };
}Manual Token Refresh
Use with your own HTTP client (axios, ky, etc.):
import { createRetoken } from 'ts-retoken';
import axios from 'axios';
const retoken = createRetoken({ /* config */ });
// Ensure valid token before axios request
async function apiRequest(url: string) {
if (retoken.isTokenExpiringSoon()) {
await retoken.refreshToken();
}
return axios.get(url, {
headers: {
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
},
});
}Custom Retry Configuration
const retoken = createRetoken({
// ...
retry: {
delays: [1000, 2000, 4000, 8000], // 4 retries
skipOnClientError: true,
},
expirationLeeway: 30, // Refresh 30s before expiration
});Custom Status Codes
const retoken = createRetoken({
// ...
// Original request: which statuses trigger refresh + retry
retryStatuses: [401, 403],
// Refresh request: which statuses mean "auth failed completely"
refreshFailureStatuses: [401, 403, 422],
});How It Works
Proactive Refresh: Before each request, checks if the access token expires within
expirationLeewayseconds. If so, refreshes the token first.Fallback Refresh: If the request returns a status in
retryStatuses(default: 401), attempts to refresh the token and retries the request.Request Deduplication: If multiple requests trigger a refresh simultaneously, only one refresh request is made. All pending requests wait for the same refresh promise.
Retry with Backoff: Failed refresh requests are retried with exponential backoff (default: 3s, 6s, 12s). Client errors (4xx) are not retried.
Auth Failure: When the refresh request returns a status in
refreshFailureStatuses,onAuthFailureis called and no more retries are attempted.
License
MIT
