From b6654f27aec6b787bf66fcd6cfe29c168ec5e484 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Mon, 26 Jan 2026 19:16:58 -0600 Subject: [PATCH] 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 --- src/lib/apiClient.ts | 248 +++++++++++++++++++++++++++++++++++ src/services/auth.service.ts | 161 +++++++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 src/lib/apiClient.ts create mode 100644 src/services/auth.service.ts diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts new file mode 100644 index 0000000..4c31797 --- /dev/null +++ b/src/lib/apiClient.ts @@ -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 => { + const refreshToken = getRefreshToken(); + + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + try { + const response = await axios.post( + `${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; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000..e145677 --- /dev/null +++ b/src/services/auth.service.ts @@ -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 { + 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 { + 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 { + 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;