From cf5bb41c17225c9d303262a46e8f5af3fbb63143 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Fri, 31 Oct 2025 22:00:45 +0100 Subject: [PATCH] Refactor config, auth, and storage modules with runtime validation and encryption - Centralized and refactored configuration management (`config`) with runtime validation for environment variables. - Introduced utilities for secure token storage, including AES-GCM encryption and fallback handling. - Enhanced `authStore` state management with token validation, secure persistence, and initialization from storage. - Modularized authentication utilities and updated export structure for better maintainability. - Improved error handling, input validation, and added detailed comments for enhanced clarity. --- .gitignore | 1 - frontend/src/config/app.config.ts | 168 ++++++++++++++ frontend/src/config/index.old.ts | 27 +++ frontend/src/config/index.ts | 165 +++++++++++++- frontend/src/lib/api/client.old.ts | 154 +++++++++++++ frontend/src/lib/api/client.ts | 185 ++++++++++------ .../src/lib/auth/__tests__/crypto.test.ts | 109 +++++++++ frontend/src/lib/auth/crypto.ts | 108 +++++++++ frontend/src/lib/auth/index.ts | 17 +- frontend/src/lib/auth/storage.ts | 128 +++++++++++ frontend/src/stores/authStore.old.ts | 77 +++++++ frontend/src/stores/authStore.ts | 207 +++++++++++++----- frontend/src/stores/index.ts | 2 +- 13 files changed, 1220 insertions(+), 128 deletions(-) mode change 100644 => 100755 .gitignore create mode 100644 frontend/src/config/app.config.ts create mode 100755 frontend/src/config/index.old.ts mode change 100755 => 100644 frontend/src/config/index.ts create mode 100755 frontend/src/lib/api/client.old.ts mode change 100755 => 100644 frontend/src/lib/api/client.ts create mode 100644 frontend/src/lib/auth/__tests__/crypto.test.ts create mode 100644 frontend/src/lib/auth/crypto.ts create mode 100644 frontend/src/lib/auth/storage.ts create mode 100755 frontend/src/stores/authStore.old.ts mode change 100755 => 100644 frontend/src/stores/authStore.ts diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 64381b4..8a2b8af --- a/.gitignore +++ b/.gitignore @@ -147,7 +147,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/frontend/src/config/app.config.ts b/frontend/src/config/app.config.ts new file mode 100644 index 0000000..28ea9a9 --- /dev/null +++ b/frontend/src/config/app.config.ts @@ -0,0 +1,168 @@ +/** + * Application configuration with runtime validation + * Centralized config prevents scattered environment variable access + */ + +/** + * Safely parse integer with validation + * @param value - String value to parse + * @param defaultValue - Fallback if parsing fails + * @param min - Optional minimum value + * @param max - Optional maximum value + * @returns Valid integer or default + */ +function parseIntSafe( + value: string | undefined, + defaultValue: number, + min?: number, + max?: number +): number { + if (!value) return defaultValue; + + const parsed = parseInt(value, 10); + + if (isNaN(parsed)) { + console.warn(`Invalid integer value: "${value}", using default: ${defaultValue}`); + return defaultValue; + } + + if (min !== undefined && parsed < min) { + console.warn(`Value ${parsed} below minimum ${min}, using minimum`); + return min; + } + + if (max !== undefined && parsed > max) { + console.warn(`Value ${parsed} above maximum ${max}, using maximum`); + return max; + } + + return parsed; +} + +/** + * Parse boolean from string + */ +function parseBool(value: string | undefined, defaultValue: boolean): boolean { + if (value === undefined) return defaultValue; + return value === 'true'; +} + +/** + * Validate URL format + */ +function validateUrl(url: string, name: string): string { + try { + new URL(url); + return url; + } catch { + throw new Error(`Invalid URL for ${name}: ${url}`); + } +} + +// Parse and validate all environment variables once +const ENV = { + API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000', + API_TIMEOUT: process.env.NEXT_PUBLIC_API_TIMEOUT, + APP_NAME: process.env.NEXT_PUBLIC_APP_NAME || 'Template Project', + APP_URL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', + TOKEN_REFRESH_THRESHOLD: process.env.NEXT_PUBLIC_TOKEN_REFRESH_THRESHOLD, + ACCESS_TOKEN_EXPIRY: process.env.NEXT_PUBLIC_ACCESS_TOKEN_EXPIRY, + REFRESH_TOKEN_EXPIRY: process.env.NEXT_PUBLIC_REFRESH_TOKEN_EXPIRY, + ENABLE_REGISTRATION: process.env.NEXT_PUBLIC_ENABLE_REGISTRATION, + ENABLE_SESSION_MANAGEMENT: process.env.NEXT_PUBLIC_ENABLE_SESSION_MANAGEMENT, + DEBUG_API: process.env.NEXT_PUBLIC_DEBUG_API, + NODE_ENV: process.env.NODE_ENV || 'development', +} as const; + +/** + * Application configuration object + * All config is typed and validated + */ +export const config = { + api: { + baseUrl: validateUrl(ENV.API_BASE_URL, 'API_BASE_URL'), + // Construct versioned API URL consistently + url: `${validateUrl(ENV.API_BASE_URL, 'API_BASE_URL')}/api/v1`, + timeout: parseIntSafe(ENV.API_TIMEOUT, 30000, 1000, 120000), // 1s to 2min + }, + + app: { + name: ENV.APP_NAME, + url: validateUrl(ENV.APP_URL, 'APP_URL'), + }, + + auth: { + // Time before token expiry to trigger refresh (5 min default) + tokenRefreshThreshold: parseIntSafe(ENV.TOKEN_REFRESH_THRESHOLD, 300000, 10000), + // Expected token expiry times (for validation) + accessTokenExpiry: parseIntSafe(ENV.ACCESS_TOKEN_EXPIRY, 900000, 60000), // 15 min default, min 1min + refreshTokenExpiry: parseIntSafe(ENV.REFRESH_TOKEN_EXPIRY, 604800000, 3600000), // 7 days default, min 1hr + }, + + routes: { + login: '/login', + register: '/register', + home: '/', + dashboard: '/dashboard', + profile: '/profile', + settings: '/settings', + adminDashboard: '/admin', + }, + + features: { + enableRegistration: parseBool(ENV.ENABLE_REGISTRATION, true), + enableSessionManagement: parseBool(ENV.ENABLE_SESSION_MANAGEMENT, true), + }, + + debug: { + api: parseBool(ENV.DEBUG_API, false) && ENV.NODE_ENV === 'development', + }, + + env: { + isDevelopment: ENV.NODE_ENV === 'development', + isProduction: ENV.NODE_ENV === 'production', + isTest: ENV.NODE_ENV === 'test', + }, +} as const; + +// Type export for IDE autocomplete +export type AppConfig = typeof config; + +/** + * Validate critical configuration on module load + */ +function validateConfig(): void { + const errors: string[] = []; + + // Validate API configuration + if (!config.api.baseUrl) { + errors.push('API base URL is required'); + } + + if (config.api.timeout < 1000) { + errors.push('API timeout must be at least 1000ms'); + } + + // Validate auth configuration + if (config.auth.accessTokenExpiry <= 0) { + errors.push('Access token expiry must be positive'); + } + + if (config.auth.refreshTokenExpiry <= config.auth.accessTokenExpiry) { + errors.push('Refresh token expiry must be greater than access token expiry'); + } + + if (errors.length > 0) { + console.error('Configuration validation failed:'); + errors.forEach((error) => console.error(` - ${error}`)); + throw new Error('Invalid application configuration'); + } +} + +// Run validation on import +if (typeof window !== 'undefined') { + validateConfig(); +} + +// Export default for convenience +export default config; diff --git a/frontend/src/config/index.old.ts b/frontend/src/config/index.old.ts new file mode 100755 index 0000000..5376739 --- /dev/null +++ b/frontend/src/config/index.old.ts @@ -0,0 +1,27 @@ +// Application configuration +// Environment variables, constants, feature flags, etc. + +export const config = { + api: { + baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1', + timeout: parseInt(process.env.NEXT_PUBLIC_API_TIMEOUT || '30000', 10), + }, + app: { + name: process.env.NEXT_PUBLIC_APP_NAME || 'Template Project', + url: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', + }, + auth: { + tokenRefreshThreshold: parseInt(process.env.NEXT_PUBLIC_TOKEN_REFRESH_THRESHOLD || '300000', 10), + accessTokenExpiry: parseInt(process.env.NEXT_PUBLIC_ACCESS_TOKEN_EXPIRY || '900000', 10), + refreshTokenExpiry: parseInt(process.env.NEXT_PUBLIC_REFRESH_TOKEN_EXPIRY || '604800000', 10), + }, + features: { + enableRegistration: process.env.NEXT_PUBLIC_ENABLE_REGISTRATION === 'true', + enableSessionManagement: process.env.NEXT_PUBLIC_ENABLE_SESSION_MANAGEMENT === 'true', + }, + debug: { + api: process.env.NEXT_PUBLIC_DEBUG_API === 'true', + }, +} as const; + +export type AppConfig = typeof config; diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts old mode 100755 new mode 100644 index 5376739..28ea9a9 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -1,27 +1,168 @@ -// Application configuration -// Environment variables, constants, feature flags, etc. +/** + * Application configuration with runtime validation + * Centralized config prevents scattered environment variable access + */ +/** + * Safely parse integer with validation + * @param value - String value to parse + * @param defaultValue - Fallback if parsing fails + * @param min - Optional minimum value + * @param max - Optional maximum value + * @returns Valid integer or default + */ +function parseIntSafe( + value: string | undefined, + defaultValue: number, + min?: number, + max?: number +): number { + if (!value) return defaultValue; + + const parsed = parseInt(value, 10); + + if (isNaN(parsed)) { + console.warn(`Invalid integer value: "${value}", using default: ${defaultValue}`); + return defaultValue; + } + + if (min !== undefined && parsed < min) { + console.warn(`Value ${parsed} below minimum ${min}, using minimum`); + return min; + } + + if (max !== undefined && parsed > max) { + console.warn(`Value ${parsed} above maximum ${max}, using maximum`); + return max; + } + + return parsed; +} + +/** + * Parse boolean from string + */ +function parseBool(value: string | undefined, defaultValue: boolean): boolean { + if (value === undefined) return defaultValue; + return value === 'true'; +} + +/** + * Validate URL format + */ +function validateUrl(url: string, name: string): string { + try { + new URL(url); + return url; + } catch { + throw new Error(`Invalid URL for ${name}: ${url}`); + } +} + +// Parse and validate all environment variables once +const ENV = { + API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000', + API_TIMEOUT: process.env.NEXT_PUBLIC_API_TIMEOUT, + APP_NAME: process.env.NEXT_PUBLIC_APP_NAME || 'Template Project', + APP_URL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', + TOKEN_REFRESH_THRESHOLD: process.env.NEXT_PUBLIC_TOKEN_REFRESH_THRESHOLD, + ACCESS_TOKEN_EXPIRY: process.env.NEXT_PUBLIC_ACCESS_TOKEN_EXPIRY, + REFRESH_TOKEN_EXPIRY: process.env.NEXT_PUBLIC_REFRESH_TOKEN_EXPIRY, + ENABLE_REGISTRATION: process.env.NEXT_PUBLIC_ENABLE_REGISTRATION, + ENABLE_SESSION_MANAGEMENT: process.env.NEXT_PUBLIC_ENABLE_SESSION_MANAGEMENT, + DEBUG_API: process.env.NEXT_PUBLIC_DEBUG_API, + NODE_ENV: process.env.NODE_ENV || 'development', +} as const; + +/** + * Application configuration object + * All config is typed and validated + */ export const config = { api: { - baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1', - timeout: parseInt(process.env.NEXT_PUBLIC_API_TIMEOUT || '30000', 10), + baseUrl: validateUrl(ENV.API_BASE_URL, 'API_BASE_URL'), + // Construct versioned API URL consistently + url: `${validateUrl(ENV.API_BASE_URL, 'API_BASE_URL')}/api/v1`, + timeout: parseIntSafe(ENV.API_TIMEOUT, 30000, 1000, 120000), // 1s to 2min }, + app: { - name: process.env.NEXT_PUBLIC_APP_NAME || 'Template Project', - url: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', + name: ENV.APP_NAME, + url: validateUrl(ENV.APP_URL, 'APP_URL'), }, + auth: { - tokenRefreshThreshold: parseInt(process.env.NEXT_PUBLIC_TOKEN_REFRESH_THRESHOLD || '300000', 10), - accessTokenExpiry: parseInt(process.env.NEXT_PUBLIC_ACCESS_TOKEN_EXPIRY || '900000', 10), - refreshTokenExpiry: parseInt(process.env.NEXT_PUBLIC_REFRESH_TOKEN_EXPIRY || '604800000', 10), + // Time before token expiry to trigger refresh (5 min default) + tokenRefreshThreshold: parseIntSafe(ENV.TOKEN_REFRESH_THRESHOLD, 300000, 10000), + // Expected token expiry times (for validation) + accessTokenExpiry: parseIntSafe(ENV.ACCESS_TOKEN_EXPIRY, 900000, 60000), // 15 min default, min 1min + refreshTokenExpiry: parseIntSafe(ENV.REFRESH_TOKEN_EXPIRY, 604800000, 3600000), // 7 days default, min 1hr }, + + routes: { + login: '/login', + register: '/register', + home: '/', + dashboard: '/dashboard', + profile: '/profile', + settings: '/settings', + adminDashboard: '/admin', + }, + features: { - enableRegistration: process.env.NEXT_PUBLIC_ENABLE_REGISTRATION === 'true', - enableSessionManagement: process.env.NEXT_PUBLIC_ENABLE_SESSION_MANAGEMENT === 'true', + enableRegistration: parseBool(ENV.ENABLE_REGISTRATION, true), + enableSessionManagement: parseBool(ENV.ENABLE_SESSION_MANAGEMENT, true), }, + debug: { - api: process.env.NEXT_PUBLIC_DEBUG_API === 'true', + api: parseBool(ENV.DEBUG_API, false) && ENV.NODE_ENV === 'development', + }, + + env: { + isDevelopment: ENV.NODE_ENV === 'development', + isProduction: ENV.NODE_ENV === 'production', + isTest: ENV.NODE_ENV === 'test', }, } as const; +// Type export for IDE autocomplete export type AppConfig = typeof config; + +/** + * Validate critical configuration on module load + */ +function validateConfig(): void { + const errors: string[] = []; + + // Validate API configuration + if (!config.api.baseUrl) { + errors.push('API base URL is required'); + } + + if (config.api.timeout < 1000) { + errors.push('API timeout must be at least 1000ms'); + } + + // Validate auth configuration + if (config.auth.accessTokenExpiry <= 0) { + errors.push('Access token expiry must be positive'); + } + + if (config.auth.refreshTokenExpiry <= config.auth.accessTokenExpiry) { + errors.push('Refresh token expiry must be greater than access token expiry'); + } + + if (errors.length > 0) { + console.error('Configuration validation failed:'); + errors.forEach((error) => console.error(` - ${error}`)); + throw new Error('Invalid application configuration'); + } +} + +// Run validation on import +if (typeof window !== 'undefined') { + validateConfig(); +} + +// Export default for convenience +export default config; diff --git a/frontend/src/lib/api/client.old.ts b/frontend/src/lib/api/client.old.ts new file mode 100755 index 0000000..db2fc16 --- /dev/null +++ b/frontend/src/lib/api/client.old.ts @@ -0,0 +1,154 @@ +import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'; +import { useAuthStore } from '@/stores/authStore'; +import { parseAPIError, type APIErrorResponse } from './errors'; + +// Create Axios instance +export const apiClient = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1', + timeout: parseInt(process.env.NEXT_PUBLIC_API_TIMEOUT || '30000'), + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor - Add authentication token +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + // Get access token from auth store + const accessToken = useAuthStore.getState().accessToken; + + // Add Authorization header if token exists + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + // Log request in development + if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_DEBUG_API === 'true') { + console.log('🚀 API Request:', { + method: config.method?.toUpperCase(), + url: config.url, + headers: config.headers, + data: config.data, + }); + } + + return config; + }, + (error: AxiosError) => { + console.error('Request interceptor error:', error); + return Promise.reject(error); + } +); + +// Response interceptor - Handle errors and token refresh +apiClient.interceptors.response.use( + (response) => { + // Log response in development + if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_DEBUG_API === 'true') { + console.log('✅ API Response:', { + status: response.status, + url: response.config.url, + data: response.data, + }); + } + + return response; + }, + async (error: AxiosError) => { + const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }; + + // Log error in development + if (process.env.NODE_ENV === 'development') { + console.error('❌ API Error:', { + status: error.response?.status, + url: error.config?.url, + message: error.message, + data: error.response?.data, + }); + } + + // Handle 401 Unauthorized - Token refresh logic + if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { + originalRequest._retry = true; + + try { + // Get refresh token + const refreshToken = useAuthStore.getState().refreshToken; + + if (!refreshToken) { + // No refresh token - redirect to login + useAuthStore.getState().clearAuth(); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + return Promise.reject(error); + } + + // Attempt to refresh tokens + const response = await axios.post( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/refresh`, + { refresh_token: refreshToken }, + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + const { access_token, refresh_token } = response.data; + + // Update tokens in store + useAuthStore.getState().setTokens(access_token, refresh_token); + + // Retry original request with new token + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${access_token}`; + } + + return apiClient.request(originalRequest); + } catch (refreshError) { + // Refresh failed - clear auth and redirect to login + console.error('Token refresh failed:', refreshError); + useAuthStore.getState().clearAuth(); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + return Promise.reject(refreshError); + } + } + + // Handle 403 Forbidden + if (error.response?.status === 403) { + console.warn('Access forbidden - insufficient permissions'); + // You might want to show a toast here + } + + // Handle 429 Too Many Requests + if (error.response?.status === 429) { + console.warn('Rate limit exceeded'); + // You might want to show a toast with retry time + const retryAfter = error.response.headers['retry-after']; + if (retryAfter) { + console.log(`Retry after ${retryAfter} seconds`); + } + } + + // Handle 500+ Server Errors + if (error.response?.status && error.response.status >= 500) { + console.error('Server error occurred'); + // You might want to show a generic error toast + } + + // Handle Network Errors + if (!error.response) { + console.error('Network error - check your connection'); + // You might want to show a network error toast + } + + // Parse and reject with structured error + const parsedErrors = parseAPIError(error); + return Promise.reject(parsedErrors); + } +); + +export default apiClient; diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts old mode 100755 new mode 100644 index db2fc16..a816c09 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -1,38 +1,129 @@ +/** + * API Client with secure token refresh and error handling + * Implements singleton refresh pattern to prevent race conditions + */ + import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'; import { useAuthStore } from '@/stores/authStore'; import { parseAPIError, type APIErrorResponse } from './errors'; +import config from '@/config'; -// Create Axios instance -export const apiClient = axios.create({ - baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1', - timeout: parseInt(process.env.NEXT_PUBLIC_API_TIMEOUT || '30000'), +/** + * Separate axios instance for auth endpoints + * Prevents interceptor loops during token refresh + */ +const authClient = axios.create({ + baseURL: config.api.url, + timeout: config.api.timeout, headers: { 'Content-Type': 'application/json', }, }); -// Request interceptor - Add authentication token +/** + * Main API client instance + */ +export const apiClient = axios.create({ + baseURL: config.api.url, + timeout: config.api.timeout, + headers: { + 'Content-Type': 'application/json', + }, +}); + +/** + * Token refresh state + * Singleton pattern prevents multiple simultaneous refresh requests + */ +let isRefreshing = false; +let refreshPromise: Promise | null = null; + +/** + * Refresh access token using refresh token + * Implements singleton pattern to prevent race conditions + * @returns Promise resolving to new access token + */ +async function refreshAccessToken(): Promise { + // If already refreshing, return existing promise + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + // Start new refresh + isRefreshing = true; + + refreshPromise = (async () => { + try { + const refreshToken = useAuthStore.getState().refreshToken; + + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + // Use separate auth client to avoid interceptor loop + const response = await authClient.post<{ + access_token: string; + refresh_token: string; + expires_in?: number; + token_type: string; + }>('/auth/refresh', { + refresh_token: refreshToken, + }); + + // Validate response structure + if (!response.data?.access_token || !response.data?.refresh_token) { + throw new Error('Invalid refresh response format'); + } + + const { access_token, refresh_token, expires_in } = response.data; + + // Update tokens in store + await useAuthStore.getState().setTokens(access_token, refresh_token, expires_in); + + return access_token; + } catch (error) { + // Refresh failed - clear auth and redirect + console.error('Token refresh failed:', error); + await useAuthStore.getState().clearAuth(); + + if (typeof window !== 'undefined') { + window.location.href = config.routes.login; + } + + throw error; + } finally { + // Reset refresh state + isRefreshing = false; + refreshPromise = null; + } + })(); + + return refreshPromise; +} + +/** + * Request interceptor - Add authentication token + */ apiClient.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { + (requestConfig: InternalAxiosRequestConfig) => { // Get access token from auth store const accessToken = useAuthStore.getState().accessToken; // Add Authorization header if token exists if (accessToken) { - config.headers.Authorization = `Bearer ${accessToken}`; + requestConfig.headers.Authorization = `Bearer ${accessToken}`; } - // Log request in development - if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_DEBUG_API === 'true') { + // Debug logging in development + if (config.debug.api) { console.log('🚀 API Request:', { - method: config.method?.toUpperCase(), - url: config.url, - headers: config.headers, - data: config.data, + method: requestConfig.method?.toUpperCase(), + url: requestConfig.url, + hasAuth: !!accessToken, }); } - return config; + return requestConfig; }, (error: AxiosError) => { console.error('Request interceptor error:', error); @@ -40,15 +131,16 @@ apiClient.interceptors.request.use( } ); -// Response interceptor - Handle errors and token refresh +/** + * Response interceptor - Handle errors and token refresh + */ apiClient.interceptors.response.use( (response) => { - // Log response in development - if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_DEBUG_API === 'true') { + // Debug logging in development + if (config.debug.api) { console.log('✅ API Response:', { status: response.status, url: response.config.url, - data: response.data, }); } @@ -57,13 +149,12 @@ apiClient.interceptors.response.use( async (error: AxiosError) => { const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }; - // Log error in development - if (process.env.NODE_ENV === 'development') { + // Debug logging in development + if (config.env.isDevelopment) { console.error('❌ API Error:', { status: error.response?.status, url: error.config?.url, message: error.message, - data: error.response?.data, }); } @@ -72,47 +163,17 @@ apiClient.interceptors.response.use( originalRequest._retry = true; try { - // Get refresh token - const refreshToken = useAuthStore.getState().refreshToken; - - if (!refreshToken) { - // No refresh token - redirect to login - useAuthStore.getState().clearAuth(); - if (typeof window !== 'undefined') { - window.location.href = '/login'; - } - return Promise.reject(error); - } - - // Attempt to refresh tokens - const response = await axios.post( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/refresh`, - { refresh_token: refreshToken }, - { - headers: { - 'Content-Type': 'application/json', - }, - } - ); - - const { access_token, refresh_token } = response.data; - - // Update tokens in store - useAuthStore.getState().setTokens(access_token, refresh_token); + // Attempt to refresh token (singleton pattern) + const newAccessToken = await refreshAccessToken(); // Retry original request with new token if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${access_token}`; + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; } return apiClient.request(originalRequest); } catch (refreshError) { - // Refresh failed - clear auth and redirect to login - console.error('Token refresh failed:', refreshError); - useAuthStore.getState().clearAuth(); - if (typeof window !== 'undefined') { - window.location.href = '/login'; - } + // Refresh failed - error already logged, auth cleared return Promise.reject(refreshError); } } @@ -120,29 +181,26 @@ apiClient.interceptors.response.use( // Handle 403 Forbidden if (error.response?.status === 403) { console.warn('Access forbidden - insufficient permissions'); - // You might want to show a toast here + // Toast notification would be added here in Phase 4 } // Handle 429 Too Many Requests if (error.response?.status === 429) { - console.warn('Rate limit exceeded'); - // You might want to show a toast with retry time const retryAfter = error.response.headers['retry-after']; - if (retryAfter) { - console.log(`Retry after ${retryAfter} seconds`); - } + console.warn(`Rate limit exceeded${retryAfter ? `. Retry after ${retryAfter}s` : ''}`); + // Toast notification would be added here in Phase 4 } // Handle 500+ Server Errors if (error.response?.status && error.response.status >= 500) { console.error('Server error occurred'); - // You might want to show a generic error toast + // Toast notification would be added here in Phase 4 } // Handle Network Errors if (!error.response) { console.error('Network error - check your connection'); - // You might want to show a network error toast + // Toast notification would be added here in Phase 4 } // Parse and reject with structured error @@ -151,4 +209,5 @@ apiClient.interceptors.response.use( } ); +// Export default for convenience export default apiClient; diff --git a/frontend/src/lib/auth/__tests__/crypto.test.ts b/frontend/src/lib/auth/__tests__/crypto.test.ts new file mode 100644 index 0000000..a4e5097 --- /dev/null +++ b/frontend/src/lib/auth/__tests__/crypto.test.ts @@ -0,0 +1,109 @@ +/** + * Tests for crypto utilities + */ + +import { encryptData, decryptData, clearEncryptionKey } from '../crypto'; + +describe('Crypto Utilities', () => { + beforeEach(() => { + // Clear encryption key before each test + clearEncryptionKey(); + sessionStorage.clear(); + }); + + describe('encryptData', () => { + it('should encrypt string data', async () => { + const plaintext = 'test data'; + const encrypted = await encryptData(plaintext); + + expect(encrypted).toBeDefined(); + expect(typeof encrypted).toBe('string'); + expect(encrypted).not.toBe(plaintext); + }); + + it('should produce different ciphertext for same plaintext', async () => { + const plaintext = 'test data'; + const encrypted1 = await encryptData(plaintext); + const encrypted2 = await encryptData(plaintext); + + // Due to random IV, ciphertexts should be different + expect(encrypted1).not.toBe(encrypted2); + }); + + it('should handle empty strings', async () => { + const encrypted = await encryptData(''); + expect(encrypted).toBeDefined(); + }); + + it('should handle special characters', async () => { + const special = '!@#$%^&*()_+-=[]{}|;:",.<>?/~`'; + const encrypted = await encryptData(special); + const decrypted = await decryptData(encrypted); + + expect(decrypted).toBe(special); + }); + }); + + describe('decryptData', () => { + it('should decrypt data encrypted by encryptData', async () => { + const plaintext = 'test data'; + const encrypted = await encryptData(plaintext); + const decrypted = await decryptData(encrypted); + + expect(decrypted).toBe(plaintext); + }); + + it('should throw error for invalid encrypted data', async () => { + await expect(decryptData('invalid')).rejects.toThrow(); + }); + + it('should throw error for tampered data', async () => { + const plaintext = 'test data'; + const encrypted = await encryptData(plaintext); + + // Tamper with encrypted data + const tampered = encrypted.slice(0, -4) + 'XXXX'; + + await expect(decryptData(tampered)).rejects.toThrow(); + }); + }); + + describe('clearEncryptionKey', () => { + it('should clear encryption key from session', async () => { + // Encrypt some data (creates key) + await encryptData('test'); + + // Clear key + clearEncryptionKey(); + + // Session storage should be empty + expect(sessionStorage.getItem('auth_encryption_key')).toBeNull(); + }); + + it('should invalidate previously encrypted data after key cleared', async () => { + const plaintext = 'test data'; + const encrypted = await encryptData(plaintext); + + // Clear key + clearEncryptionKey(); + + // Try to decrypt - should fail because key is regenerated + await expect(decryptData(encrypted)).rejects.toThrow(); + }); + }); + + describe('Key persistence', () => { + it('should reuse same key within session', async () => { + const plaintext = 'test data'; + + const encrypted1 = await encryptData(plaintext); + const decrypted1 = await decryptData(encrypted1); + + const encrypted2 = await encryptData(plaintext); + const decrypted2 = await decryptData(encrypted2); + + expect(decrypted1).toBe(plaintext); + expect(decrypted2).toBe(plaintext); + }); + }); +}); diff --git a/frontend/src/lib/auth/crypto.ts b/frontend/src/lib/auth/crypto.ts new file mode 100644 index 0000000..d568caf --- /dev/null +++ b/frontend/src/lib/auth/crypto.ts @@ -0,0 +1,108 @@ +/** + * Cryptographic utilities for secure token storage + * Implements AES-GCM encryption for localStorage fallback + */ + +const ENCRYPTION_KEY_NAME = 'auth_encryption_key'; + +/** + * Generate or retrieve encryption key + * Key is stored in sessionStorage (cleared on browser close) + */ +async function getEncryptionKey(): Promise { + // Check if key exists in session + const storedKey = sessionStorage.getItem(ENCRYPTION_KEY_NAME); + + if (storedKey) { + const keyData = JSON.parse(storedKey); + return await crypto.subtle.importKey( + 'jwk', + keyData, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + } + + // Generate new key + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + + // Store key in sessionStorage + const exportedKey = await crypto.subtle.exportKey('jwk', key); + sessionStorage.setItem(ENCRYPTION_KEY_NAME, JSON.stringify(exportedKey)); + + return key; +} + +/** + * Encrypt data using AES-GCM + * @param data - Data to encrypt + * @returns Base64 encoded encrypted data with IV + */ +export async function encryptData(data: string): Promise { + try { + const key = await getEncryptionKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV for GCM + const encoder = new TextEncoder(); + const encodedData = encoder.encode(data); + + const encryptedData = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encodedData + ); + + // Combine IV and encrypted data + const combined = new Uint8Array(iv.length + encryptedData.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encryptedData), iv.length); + + // Convert to base64 + return btoa(String.fromCharCode(...combined)); + } catch (error) { + console.error('Encryption failed:', error); + throw new Error('Failed to encrypt data'); + } +} + +/** + * Decrypt data encrypted with encryptData + * @param encryptedData - Base64 encoded encrypted data with IV + * @returns Decrypted string + */ +export async function decryptData(encryptedData: string): Promise { + try { + const key = await getEncryptionKey(); + + // Decode from base64 + const combined = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0)); + + // Extract IV and encrypted data + const iv = combined.slice(0, 12); + const data = combined.slice(12); + + const decryptedData = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + data + ); + + const decoder = new TextDecoder(); + return decoder.decode(decryptedData); + } catch (error) { + console.error('Decryption failed:', error); + throw new Error('Failed to decrypt data'); + } +} + +/** + * Clear encryption key from session + * Call this on logout to invalidate encrypted data + */ +export function clearEncryptionKey(): void { + sessionStorage.removeItem(ENCRYPTION_KEY_NAME); +} diff --git a/frontend/src/lib/auth/index.ts b/frontend/src/lib/auth/index.ts index 3bc8ce1..29ef3e5 100755 --- a/frontend/src/lib/auth/index.ts +++ b/frontend/src/lib/auth/index.ts @@ -1,4 +1,19 @@ // Authentication utilities // Examples: Token management, auth helpers, session utilities, etc. -export {}; +export { + encryptData, + decryptData, + clearEncryptionKey, +} from './crypto'; + +export { + saveTokens, + getTokens, + clearTokens, + getStorageMethod, + setStorageMethod, + isStorageAvailable, + type TokenStorage, + type StorageMethod, +} from './storage'; diff --git a/frontend/src/lib/auth/storage.ts b/frontend/src/lib/auth/storage.ts new file mode 100644 index 0000000..1171260 --- /dev/null +++ b/frontend/src/lib/auth/storage.ts @@ -0,0 +1,128 @@ +/** + * Secure token storage abstraction + * Primary: httpOnly cookies (server-side) + * Fallback: Encrypted localStorage (client-side) + */ + +import { encryptData, decryptData, clearEncryptionKey } from './crypto'; + +export interface TokenStorage { + accessToken: string | null; + refreshToken: string | null; +} + +const STORAGE_KEY = 'auth_tokens'; +const STORAGE_METHOD_KEY = 'auth_storage_method'; + +export type StorageMethod = 'cookie' | 'localStorage'; + +/** + * Get current storage method + */ +export function getStorageMethod(): StorageMethod { + // Check if we previously set a storage method + const stored = localStorage.getItem(STORAGE_METHOD_KEY); + if (stored === 'cookie' || stored === 'localStorage') { + return stored; + } + + // Default to localStorage for client-side auth + // In Phase 2, we'll add cookie detection from server + return 'localStorage'; +} + +/** + * Set storage method preference + */ +export function setStorageMethod(method: StorageMethod): void { + localStorage.setItem(STORAGE_METHOD_KEY, method); +} + +/** + * Save tokens securely + * @param tokens - Access and refresh tokens + */ +export async function saveTokens(tokens: TokenStorage): Promise { + const method = getStorageMethod(); + + if (method === 'cookie') { + // Cookies are handled server-side via Set-Cookie headers + // This is a no-op for client-side, actual implementation in Phase 2 + // when we add server-side API route handlers + console.debug('Token storage via cookies (server-side)'); + return; + } + + // Fallback: Encrypted localStorage + try { + const encrypted = await encryptData(JSON.stringify(tokens)); + localStorage.setItem(STORAGE_KEY, encrypted); + } catch (error) { + console.error('Failed to save tokens:', error); + throw new Error('Token storage failed'); + } +} + +/** + * Retrieve tokens from storage + * @returns Stored tokens or null + */ +export async function getTokens(): Promise { + const method = getStorageMethod(); + + if (method === 'cookie') { + // Cookies are sent automatically with requests + // For client-side access, we'll implement this in Phase 2 + // with a /api/auth/session endpoint + console.debug('Token retrieval via cookies (server-side)'); + return null; + } + + // Fallback: Encrypted localStorage + try { + const encrypted = localStorage.getItem(STORAGE_KEY); + if (!encrypted) { + return null; + } + + const decrypted = await decryptData(encrypted); + return JSON.parse(decrypted) as TokenStorage; + } catch (error) { + console.error('Failed to retrieve tokens:', error); + // If decryption fails, clear invalid data + localStorage.removeItem(STORAGE_KEY); + return null; + } +} + +/** + * Clear all tokens from storage + */ +export async function clearTokens(): Promise { + const method = getStorageMethod(); + + if (method === 'cookie') { + // Cookie clearing via server-side Set-Cookie with Max-Age=0 + // Implementation in Phase 2 + console.debug('Token clearing via cookies (server-side)'); + } + + // Always clear localStorage (belt and suspenders) + localStorage.removeItem(STORAGE_KEY); + clearEncryptionKey(); +} + +/** + * Check if storage is available + * @returns true if localStorage is accessible + */ +export function isStorageAvailable(): boolean { + try { + const test = '__storage_test__'; + localStorage.setItem(test, test); + localStorage.removeItem(test); + return true; + } catch { + return false; + } +} diff --git a/frontend/src/stores/authStore.old.ts b/frontend/src/stores/authStore.old.ts new file mode 100755 index 0000000..322e8ca --- /dev/null +++ b/frontend/src/stores/authStore.old.ts @@ -0,0 +1,77 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +// User type - will be replaced with generated types later +interface User { + id: string; + email: string; + full_name?: string; + is_active: boolean; + is_superuser: boolean; + organization_id?: string; +} + +interface AuthState { + // State + user: User | null; + accessToken: string | null; + refreshToken: string | null; + isAuthenticated: boolean; + + // Actions + setUser: (user: User) => void; + setTokens: (accessToken: string, refreshToken: string) => void; + setAuth: (user: User, accessToken: string, refreshToken: string) => void; + clearAuth: () => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + // Initial state + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + + // Actions + setUser: (user) => + set({ + user, + isAuthenticated: true, + }), + + setTokens: (accessToken, refreshToken) => + set({ + accessToken, + refreshToken, + }), + + setAuth: (user, accessToken, refreshToken) => + set({ + user, + accessToken, + refreshToken, + isAuthenticated: true, + }), + + clearAuth: () => + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + }), + }), + { + name: 'auth-storage', // localStorage key + partialize: (state) => ({ + // Only persist these fields + user: state.user, + accessToken: state.accessToken, + refreshToken: state.refreshToken, + isAuthenticated: state.isAuthenticated, + }), + } + ) +); diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts old mode 100755 new mode 100644 index 322e8ca..05aa754 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -1,8 +1,13 @@ -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +/** + * Authentication Store - Zustand with secure token storage + * Implements proper state management with validation + */ -// User type - will be replaced with generated types later -interface User { +import { create } from 'zustand'; +import { saveTokens, getTokens, clearTokens } from '@/lib/auth/storage'; + +// User type - will be replaced with generated types in Phase 2 +export interface User { id: string; email: string; full_name?: string; @@ -17,61 +22,163 @@ interface AuthState { accessToken: string | null; refreshToken: string | null; isAuthenticated: boolean; + isLoading: boolean; + tokenExpiresAt: number | null; // Unix timestamp // Actions + setAuth: (user: User, accessToken: string, refreshToken: string, expiresIn?: number) => Promise; + setTokens: (accessToken: string, refreshToken: string, expiresIn?: number) => Promise; setUser: (user: User) => void; - setTokens: (accessToken: string, refreshToken: string) => void; - setAuth: (user: User, accessToken: string, refreshToken: string) => void; - clearAuth: () => void; + clearAuth: () => Promise; + loadAuthFromStorage: () => Promise; + isTokenExpired: () => boolean; } -export const useAuthStore = create()( - persist( - (set) => ({ - // Initial state +/** + * Validate token format (basic JWT structure check) + */ +function isValidToken(token: string): boolean { + if (!token || typeof token !== 'string') return false; + // JWT format: header.payload.signature + const parts = token.split('.'); + return parts.length === 3 && parts.every((part) => part.length > 0); +} + +/** + * Calculate token expiry timestamp + * @param expiresIn - Seconds until expiry + * @returns Unix timestamp + */ +function calculateExpiry(expiresIn?: number): number { + // Default to 15 minutes if not provided + const seconds = expiresIn || 900; + return Date.now() + seconds * 1000; +} + +export const useAuthStore = create((set, get) => ({ + // Initial state + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: true, // Start as loading to check stored tokens + tokenExpiresAt: null, + + // Set complete auth state (user + tokens) + setAuth: async (user, accessToken, refreshToken, expiresIn) => { + // Validate inputs + if (!user || !user.id || !user.email) { + throw new Error('Invalid user object'); + } + + if (!isValidToken(accessToken) || !isValidToken(refreshToken)) { + throw new Error('Invalid token format'); + } + + // Store tokens securely + try { + await saveTokens({ accessToken, refreshToken }); + + set({ + user, + accessToken, + refreshToken, + isAuthenticated: true, + isLoading: false, + tokenExpiresAt: calculateExpiry(expiresIn), + }); + } catch (error) { + console.error('Failed to save auth state:', error); + throw error; + } + }, + + // Update tokens only (for refresh flow) + setTokens: async (accessToken, refreshToken, expiresIn) => { + if (!isValidToken(accessToken) || !isValidToken(refreshToken)) { + throw new Error('Invalid token format'); + } + + try { + await saveTokens({ accessToken, refreshToken }); + + set({ + accessToken, + refreshToken, + tokenExpiresAt: calculateExpiry(expiresIn), + // Keep existing user and authenticated state + }); + } catch (error) { + console.error('Failed to update tokens:', error); + throw error; + } + }, + + // Update user only + setUser: (user) => { + if (!user || !user.id || !user.email) { + throw new Error('Invalid user object'); + } + + set({ user }); + }, + + // Clear all auth state + clearAuth: async () => { + try { + await clearTokens(); + } catch (error) { + console.error('Failed to clear tokens:', error); + } + + set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false, + isLoading: false, + tokenExpiresAt: null, + }); + }, - // Actions - setUser: (user) => - set({ - user, - isAuthenticated: true, - }), + // Load auth from storage on app start + loadAuthFromStorage: async () => { + try { + const tokens = await getTokens(); - setTokens: (accessToken, refreshToken) => - set({ - accessToken, - refreshToken, - }), - - setAuth: (user, accessToken, refreshToken) => - set({ - user, - accessToken, - refreshToken, - isAuthenticated: true, - }), - - clearAuth: () => - set({ - user: null, - accessToken: null, - refreshToken: null, - isAuthenticated: false, - }), - }), - { - name: 'auth-storage', // localStorage key - partialize: (state) => ({ - // Only persist these fields - user: state.user, - accessToken: state.accessToken, - refreshToken: state.refreshToken, - isAuthenticated: state.isAuthenticated, - }), + if (tokens?.accessToken && tokens?.refreshToken) { + // Validate token format + if (isValidToken(tokens.accessToken) && isValidToken(tokens.refreshToken)) { + set({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + isAuthenticated: true, + isLoading: false, + // User will be loaded separately via API call + }); + return; + } + } + } catch (error) { + console.error('Failed to load auth from storage:', error); } - ) -); + + // No valid tokens found + set({ isLoading: false }); + }, + + // Check if current token is expired + isTokenExpired: () => { + const { tokenExpiresAt } = get(); + if (!tokenExpiresAt) return true; + return Date.now() >= tokenExpiresAt; + }, +})); + +/** + * Initialize auth store from storage + * Call this on app startup + */ +export async function initializeAuth(): Promise { + await useAuthStore.getState().loadAuthFromStorage(); +} diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 4c7c3af..210d879 100755 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -1,4 +1,4 @@ // Zustand stores // Examples: authStore, uiStore, etc. -export { useAuthStore } from './authStore'; +export { useAuthStore, initializeAuth, type User } from './authStore';