feat(auth): Implement auto-refresh token interceptor (ST4.1)

BLOCKER-001: Auto-Refresh Tokens - Core functionality

Implemented:
- Centralized API client with auto-refresh interceptor
- Request queueing (prevents multiple simultaneous refreshes)
- Retry logic (max 1 retry per request)
- Token management (getAccessToken, getRefreshToken, setTokens, clearTokens)
- Auth service migrated to use apiClient

Files:
- src/lib/apiClient.ts (new, 237 lines)
- src/services/auth.service.ts (updated to use apiClient)

Part of ST4.1: Auto-Refresh Tokens.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-26 19:16:58 -06:00
parent e204853398
commit b6654f27ae
2 changed files with 409 additions and 0 deletions

248
src/lib/apiClient.ts Normal file
View File

@ -0,0 +1,248 @@
/**
* API Client with Auto-Refresh Token Interceptor
*
* @description Centralized Axios instance with automatic token refresh
* @blocker ST4.1 - BLOCKER-001: Auto-Refresh Tokens
* @see apps/backend/src/modules/auth/controllers/token.controller.ts - /auth/refresh endpoint
*/
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
// ============================================================================
// Types
// ============================================================================
interface TokenResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
interface RefreshResponse {
success: boolean;
data: TokenResponse;
}
// ============================================================================
// Configuration
// ============================================================================
const API_BASE_URL = import.meta.env?.VITE_API_URL || '/api/v1';
// ============================================================================
// Token Management
// ============================================================================
/**
* Get access token from storage
*/
export const getAccessToken = (): string | null => {
return localStorage.getItem('token');
};
/**
* Get refresh token from storage
*/
export const getRefreshToken = (): string | null => {
return localStorage.getItem('refreshToken');
};
/**
* Set tokens in storage
*/
export const setTokens = (accessToken: string, refreshToken: string): void => {
localStorage.setItem('token', accessToken);
localStorage.setItem('refreshToken', refreshToken);
};
/**
* Clear tokens from storage
*/
export const clearTokens = (): void => {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
};
/**
* Check if user is authenticated
*/
export const isAuthenticated = (): boolean => {
return !!getAccessToken();
};
// ============================================================================
// Refresh Token Logic
// ============================================================================
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
/**
* Process queued requests after token refresh
*/
const processQueue = (error: Error | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
/**
* Refresh access token using refresh token
*/
const refreshAccessToken = async (): Promise<string> => {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
try {
const response = await axios.post<RefreshResponse>(
`${API_BASE_URL}/auth/refresh`,
{ refreshToken },
{
headers: {
'Content-Type': 'application/json',
},
}
);
if (response.data.success && response.data.data) {
const { accessToken, refreshToken: newRefreshToken } = response.data.data;
setTokens(accessToken, newRefreshToken);
return accessToken;
}
throw new Error('Invalid refresh response');
} catch (error) {
clearTokens();
throw error;
}
};
// ============================================================================
// Axios Instance
// ============================================================================
/**
* Create Axios instance with interceptors
*/
const createApiClient = (): AxiosInstance => {
const client = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// ============================================================================
// Request Interceptor: Add Authorization Header
// ============================================================================
client.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = getAccessToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// ============================================================================
// Response Interceptor: Auto-Refresh on 401
// ============================================================================
client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// If error is not 401, reject immediately
if (error.response?.status !== 401) {
return Promise.reject(error);
}
// If request already retried, logout and redirect
if (originalRequest._retry) {
clearTokens();
window.location.href = '/login';
return Promise.reject(error);
}
// If refresh is already in progress, queue this request
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return client(originalRequest);
})
.catch((err) => Promise.reject(err));
}
// Mark request as retried
originalRequest._retry = true;
isRefreshing = true;
try {
// Attempt to refresh token
const newAccessToken = await refreshAccessToken();
// Update authorization header
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
// Process queued requests
processQueue(null, newAccessToken);
// Retry original request
return client(originalRequest);
} catch (refreshError) {
// Refresh failed, logout and redirect
processQueue(refreshError as Error, null);
clearTokens();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
return client;
};
// ============================================================================
// Export API Client Instance
// ============================================================================
/**
* Centralized API client with auto-refresh
*
* Usage:
* ```typescript
* import { apiClient } from '@/lib/apiClient';
*
* const response = await apiClient.get('/auth/me');
* ```
*/
export const apiClient = createApiClient();
export default apiClient;

View File

@ -0,0 +1,161 @@
/**
* Auth Service
* API client for authentication endpoints
* @updated ST4.1 - Migrated to centralized apiClient with auto-refresh
*/
import { apiClient, clearTokens } from '../lib/apiClient';
// ============================================================================
// Types
// ============================================================================
export interface ActiveSession {
id: string;
userAgent: string;
ipAddress: string;
createdAt: string;
lastActiveAt: string;
isCurrent: boolean;
}
export interface DeviceInfo {
type: 'desktop' | 'mobile' | 'tablet' | 'unknown';
os: string;
browser: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Parse user agent string to extract device info
*/
export function parseUserAgent(userAgent: string): DeviceInfo {
const ua = userAgent.toLowerCase();
// Detect device type
let type: DeviceInfo['type'] = 'unknown';
if (/mobile|android|iphone|ipod|blackberry|opera mini|iemobile/i.test(ua)) {
type = 'mobile';
} else if (/ipad|tablet|playbook|silk/i.test(ua)) {
type = 'tablet';
} else if (/windows|macintosh|linux|cros/i.test(ua)) {
type = 'desktop';
}
// Detect OS
let os = 'Unknown OS';
if (/windows nt 10/i.test(ua)) os = 'Windows 10/11';
else if (/windows nt 6.3/i.test(ua)) os = 'Windows 8.1';
else if (/windows nt 6.2/i.test(ua)) os = 'Windows 8';
else if (/windows nt 6.1/i.test(ua)) os = 'Windows 7';
else if (/windows/i.test(ua)) os = 'Windows';
else if (/macintosh|mac os x/i.test(ua)) os = 'macOS';
else if (/iphone|ipad|ipod/i.test(ua)) os = 'iOS';
else if (/android/i.test(ua)) os = 'Android';
else if (/linux/i.test(ua)) os = 'Linux';
else if (/cros/i.test(ua)) os = 'Chrome OS';
// Detect browser
let browser = 'Unknown Browser';
if (/edg/i.test(ua)) browser = 'Edge';
else if (/chrome/i.test(ua) && !/edg/i.test(ua)) browser = 'Chrome';
else if (/firefox/i.test(ua)) browser = 'Firefox';
else if (/safari/i.test(ua) && !/chrome/i.test(ua)) browser = 'Safari';
else if (/opera|opr/i.test(ua)) browser = 'Opera';
else if (/msie|trident/i.test(ua)) browser = 'Internet Explorer';
return { type, os, browser };
}
/**
* Format relative time
*/
export function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
}
// ============================================================================
// Session Management API
// ============================================================================
/**
* Get all active sessions for the current user
*/
export async function getSessions(): Promise<ActiveSession[]> {
try {
const response = await apiClient.get('/auth/sessions');
return response.data.data || [];
} catch (error) {
console.error('Failed to fetch sessions:', error);
throw new Error('Failed to fetch active sessions');
}
}
/**
* Revoke a specific session
*/
export async function revokeSession(sessionId: string): Promise<void> {
try {
await apiClient.delete(`/auth/sessions/${sessionId}`);
} catch (error) {
console.error('Failed to revoke session:', error);
throw new Error('Failed to revoke session');
}
}
/**
* Revoke all sessions except the current one
*/
export async function revokeAllSessions(): Promise<{ count: number }> {
try {
const response = await apiClient.post('/auth/logout-all');
return { count: parseInt(response.data.message?.match(/\d+/)?.[0] || '0') };
} catch (error) {
console.error('Failed to revoke all sessions:', error);
throw new Error('Failed to revoke all sessions');
}
}
/**
* Logout current session
*/
export async function logout(): Promise<void> {
try {
await apiClient.post('/auth/logout');
clearTokens();
} catch (error) {
console.error('Failed to logout:', error);
// Still clear local storage even if API call fails
clearTokens();
}
}
// ============================================================================
// Export as service object
// ============================================================================
export const authService = {
getSessions,
revokeSession,
revokeAllSessions,
logout,
parseUserAgent,
formatRelativeTime,
};
export default authService;