No description
|
Some checks failed
Build and Publish / build-and-publish (push) Failing after 44s
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| examples | ||
| src | ||
| .gitignore | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsup.config.ts | ||
@lilith/http-auth-interceptor
JWT authentication interceptor for HTTP clients. Handles token injection, automatic refresh on 401 errors, and retry logic.
Purpose
Separates authentication concerns from HTTP transport layer, enabling:
- Clean composition with any HTTP client (axios, fetch wrappers, etc.)
- Testing auth logic independently from transport
- Reusable auth patterns across different clients
Features
- Token Injection: Automatically adds JWT to
Authorizationheader - 401 Handling: Detects unauthorized responses, refreshes token, retries request
- Refresh Queue: Prevents "thundering herd" - only one refresh at a time
- URL Exclusion: Skip auth for public endpoints
- Customizable: Configure headers, token prefix, refresh behavior
Installation
pnpm add @lilith/http-auth-interceptor
Usage
Basic Setup
import axios from 'axios';
import { createAuthInterceptor } from '@lilith/http-auth-interceptor';
const client = axios.create({
baseURL: 'https://api.example.com'
});
createAuthInterceptor(client, {
// Get current token
getToken() {
return localStorage.getItem('auth_token');
},
// Refresh token when expired
async refreshToken() {
const refresh = localStorage.getItem('refresh_token');
const response = await axios.post('/auth/refresh', { refreshToken: refresh });
localStorage.setItem('auth_token', response.data.accessToken);
localStorage.setItem('refresh_token', response.data.refreshToken);
return response.data.accessToken;
},
// Handle refresh failure (redirect to login)
onRefreshFailed() {
localStorage.clear();
window.location.href = '/login';
}
});
// Now all requests automatically include auth token
const response = await client.get('/users');
Advanced Options
createAuthInterceptor(client, tokenSource, {
// Disable automatic token refresh (manual handling only)
enableTokenRefresh: false,
// Exclude URLs from token injection
excludeUrls: [
'/public', // String match
/^\/auth\//, // RegExp pattern
],
// Custom header name
authHeader: 'X-Auth-Token',
// Custom token prefix
tokenPrefix: 'JWT'
});
Manual Control
For more control, use AuthInterceptor class directly:
import { AuthInterceptor } from '@lilith/http-auth-interceptor';
const interceptor = new AuthInterceptor(tokenSource, client, options);
// Attach interceptors manually
client.interceptors.request.use(
interceptor.onRequest.bind(interceptor),
interceptor.onRequestError.bind(interceptor)
);
client.interceptors.response.use(
interceptor.onResponse.bind(interceptor),
interceptor.onResponseError.bind(interceptor)
);
// Cleanup (useful for testing)
interceptor.clear();
How It Works
Request Flow
- Request Interceptor: Before each request, calls
tokenSource.getToken() - If token exists, injects into
Authorization: Bearer <token>header - Skips injection for URLs in
excludeUrlslist
401 Handling Flow
- Response Interceptor: Detects 401 Unauthorized response
- Refresh Queue: Checks if refresh already in progress
- If yes: Wait for existing refresh to complete
- If no: Start new refresh by calling
tokenSource.refreshToken()
- Retry: Updates request with new token, retries original request
- Failure: If refresh fails, calls
tokenSource.onRefreshFailed(), throws error
Thundering Herd Prevention
The RefreshQueue ensures only ONE token refresh happens at a time:
// 10 requests fail with 401 simultaneously
const promises = Array(10).fill(null).map(() => client.get('/protected'));
// Only 1 refresh call happens
// Other 9 requests wait for same refresh
// All 10 retry with new token
await Promise.all(promises);
Architecture
┌─────────────────────────────────────┐
│ Application Code │
│ (import { createApiClient }) │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ @lilith/http-client │
│ (Transport: Axios wrapper) │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ @lilith/http-auth-interceptor │
│ (Auth: Token injection + refresh) │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ axios │
│ (HTTP library) │
└─────────────────────────────────────┘
JwtTokenSource Interface
Implement this interface to provide token storage/retrieval logic:
interface JwtTokenSource {
// Get current token (sync or async)
getToken(): Promise<string | null> | string | null;
// Refresh token (must be async)
refreshToken(): Promise<string | null>;
// Optional: Handle refresh failure
onRefreshFailed?(): void;
}
Example: localStorage
const tokenSource: JwtTokenSource = {
getToken() {
return localStorage.getItem('auth_token');
},
async refreshToken() {
const refresh = localStorage.getItem('refresh_token');
if (!refresh) return null;
const response = await fetch('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken: refresh })
});
const data = await response.json();
localStorage.setItem('auth_token', data.accessToken);
return data.accessToken;
},
onRefreshFailed() {
localStorage.removeItem('auth_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
}
};
Example: In-Memory Store
class TokenStore implements JwtTokenSource {
private accessToken: string | null = null;
private refreshTokenValue: string | null = null;
getToken() {
return this.accessToken;
}
async refreshToken() {
if (!this.refreshTokenValue) return null;
const response = await fetch('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken: this.refreshTokenValue })
});
const data = await response.json();
this.accessToken = data.accessToken;
this.refreshTokenValue = data.refreshToken;
return data.accessToken;
}
onRefreshFailed() {
this.clear();
}
setTokens(access: string, refresh: string) {
this.accessToken = access;
this.refreshTokenValue = refresh;
}
clear() {
this.accessToken = null;
this.refreshTokenValue = null;
}
}
Testing
import { describe, it, expect, vi } from 'vitest';
import axios from 'axios';
import { createAuthInterceptor } from '@lilith/http-auth-interceptor';
describe('Auth Integration', () => {
it('refreshes token on 401 and retries', async () => {
const client = axios.create({ baseURL: 'https://api.test' });
const tokenSource = {
getToken: vi.fn().mockReturnValue('old-token'),
refreshToken: vi.fn().mockResolvedValue('new-token'),
onRefreshFailed: vi.fn(),
};
createAuthInterceptor(client, tokenSource);
// Mock 401 response, then success
vi.spyOn(client, 'request')
.mockRejectedValueOnce({
response: { status: 401 },
config: { url: '/users', headers: {} }
})
.mockResolvedValueOnce({ data: { id: 1 } });
await client.get('/users');
expect(tokenSource.refreshToken).toHaveBeenCalled();
});
});
Migration from Old @lilith/http-client
Before (v1.x)
import { createApiClient } from '@lilith/http-client';
const client = createApiClient({
tokenStorageKey: 'auth_token',
refreshTokenStorageKey: 'refresh_token',
enableTokenRefresh: true,
handle401Redirects: true,
});
After (v2.x)
import { createApiClient } from '@lilith/http-client';
import { createAuthInterceptor } from '@lilith/http-auth-interceptor';
const client = createApiClient({
baseURL: 'https://api.example.com'
});
createAuthInterceptor(client, {
getToken() {
return localStorage.getItem('auth_token');
},
async refreshToken() {
const refresh = localStorage.getItem('refresh_token');
const response = await fetch('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken: refresh })
});
const data = await response.json();
localStorage.setItem('auth_token', data.accessToken);
return data.accessToken;
},
onRefreshFailed() {
localStorage.clear();
window.location.href = '/login';
}
});
License
MIT