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:
parent
e204853398
commit
b6654f27ae
248
src/lib/apiClient.ts
Normal file
248
src/lib/apiClient.ts
Normal 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;
|
||||
161
src/services/auth.service.ts
Normal file
161
src/services/auth.service.ts
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user