UNPKG

@pagamio/frontend-commons-lib

Version:

Pagamio library for Frontend reusable components like the form engine and table container

476 lines (475 loc) 16 kB
/** * @fileoverview Complete authentication service implementation * Handles auth operations with configurable endpoints and token management */ import { useRef } from 'react'; import { transformStrapiResponse, } from '../authenticators/processors/StrapiAuthenticatorProcessor'; import { ResponseTransformerFactory, TokenManager } from '../utils'; /** * Complete Authentication Service implementation * Uses shared API client through useApi hook for all operations * * @template T - Type extending CustomAuthConfig */ export class AuthService { /** * Keeps a reference to the interval to clear it on unmount. */ intervalRef = useRef(null); tokenManager; apiClient; endpoints; storageConfig; options; handleRedirectToLoginPage; refreshAttempts = 0; constructor(config) { this.tokenManager = config.tokenManager; this.apiClient = config.apiClient; this.handleRedirectToLoginPage = config.handleRedirectToLoginPage; // Initialize endpoints with overrides and defaults const defaultEndpoints = { login: '/auth/login', logout: '/auth/logout', refresh: '/auth/refresh', validateToken: '/auth/validate', resetPassword: '/auth/reset-password', forgotPassword: '/auth/forgot-password', changePassword: '/auth/change-password', register: '/auth/register', verifyEmail: '/auth/verify-email', updateProfile: '/auth/update-profile', }; this.endpoints = { ...defaultEndpoints, ...config.endpoints, }; // Initialize storage configuration this.storageConfig = { prefix: 'auth_', keys: ['user', 'remember', 'session'], ...config.storage, }; // Initialize options this.options = { autoRefreshToken: true, refreshThresholdMs: 5 * 60 * 1000, // 5 minutes before expiry maxRefreshAttempts: 3, ...config.options, }; // Start auto refresh if enabled if (this.options.autoRefreshToken) { this.initializeAutoRefresh(); } } /** * Creates a new AuthService instance * @param config - Service configuration * @returns Configured AuthService instance */ static create(config) { const tokenManager = new TokenManager({ baseUrl: config.baseUrl, refreshEndpoint: config.endpoints.refresh, }); return new AuthService({ ...config, tokenManager, }); } /** * Gets all configured endpoints * @returns Copy of all configured endpoint URLs */ getEndpoints() { return { ...this.endpoints }; } /** * Updates an endpoint URL * @param name - Name of the endpoint to update * @param url - New URL for the endpoint */ setEndpoint(name, url) { this.endpoints[name] = url; } /** * Adds a new custom endpoint * @param name - Name for the new endpoint * @param url - URL for the new endpoint */ addEndpoint(name, url) { this.endpoints[name] = url; } /** * Handles user login * @param credentials - User credentials * @param remember - Whether to remember the user * @returns Authentication response with user and token info */ async login(credentials, remember = false) { try { const apiResponse = await this.apiClient.post(this.getEndpointUrl('login'), credentials, { skipAuth: true, isProtected: false, }); // Handle Strapi format JWT response if (this.isStrapiAuthResponse(apiResponse)) { // Convert Strapi response to our expected format const authResponse = transformStrapiResponse(apiResponse); this.tokenManager.handleAuthTokens(authResponse.auth); this.setStorageItem('user', authResponse.user); this.setStorageItem('remember', remember); this.setStorageItem('session', { loginTime: new Date().toISOString(), lastActive: new Date().toISOString(), }); return authResponse; } // Standard flow using transformers for non-Strapi APIs const transformer = ResponseTransformerFactory.getTransformer(apiResponse); const authResponse = transformer.transform(apiResponse, remember); this.tokenManager.handleAuthTokens(authResponse.auth); this.setStorageItem('user', authResponse.user); this.setStorageItem('remember', remember); this.setStorageItem('session', { loginTime: new Date().toISOString(), lastActive: new Date().toISOString(), }); return authResponse; } catch (error) { throw this.handleError(error); } } /** * Validates if the response is a valid Strapi authentication response * @param response - API response to validate * @returns Boolean indicating if response is a valid Strapi auth response */ isStrapiAuthResponse(response) { if (!response || typeof response !== 'object') { return false; } const potentialStrapiResponse = response; // Check if jwt exists and is a non-empty string if (typeof potentialStrapiResponse.jwt !== 'string' || potentialStrapiResponse.jwt.trim() === '') { return false; } // Check if user exists and is an object if (!potentialStrapiResponse.user || typeof potentialStrapiResponse.user !== 'object') { return false; } // Optional: Add additional validation for critical user properties // For example, check if required user fields are present const user = potentialStrapiResponse.user; if (!user.id || typeof user.email !== 'string' || user.email.trim() === '') { return false; } return true; } /** * Handles user registration * @param data - Registration data, can be any object with registration information * @returns Authentication response if auto-login is enabled */ async register(data) { try { const response = await this.apiClient.post(this.getEndpointUrl('register'), data, { skipAuth: true, isProtected: false, }); // Handle Strapi format JWT response if (response && typeof response === 'object' && 'jwt' in response && 'user' in response) { // Create our expected format from Strapi response using the imported transformer const authResponse = transformStrapiResponse(response); this.tokenManager.handleAuthTokens(authResponse.auth); this.setStorageItem('user', authResponse.user); return authResponse; } // Handle standard response format const standardResponse = response; if (standardResponse.auth) { this.tokenManager.handleAuthTokens(standardResponse.auth); this.setStorageItem('user', standardResponse.user); } return standardResponse; } catch (error) { throw this.handleError(error); } } /** * Handles user logout * @param everywhere - Whether to logout from all devices */ async logout(everywhere = false) { try { // Disabled logout from the server for now, there is no backend implementation // const token = this.tokenManager.getAccessToken().token; // if (token) { // await this.apiClient.post( // this.getEndpointUrl('logout'), // { everywhere }, // { // skipRefresh: true, // isProtected: true, // }, // ); // } } catch (error) { console.error('Logout request failed:', error); } finally { this.clearAuth(); this.stopAutoRefresh(); } } /** * Refreshes authentication tokens * @returns Whether the refresh was successful */ async refreshTokens() { try { if (this.refreshAttempts >= this.options.maxRefreshAttempts) { this.clearAuth(); return false; } this.refreshAttempts++; const success = await this.tokenManager.refreshTokens(); if (success) { this.refreshAttempts = 0; if (this.options.autoRefreshToken) { this.initializeAutoRefresh(); } } else { this.clearAuth(); } return success; } catch (error) { console.error('Token refresh failed:', error); this.clearAuth(); return false; } } /** * Gets the access token expiry timestamp */ getAccessTokenExpiry() { const { expiresAt } = this.tokenManager.getAccessToken(); return expiresAt; } /** * Changes user password * @param data - Password change data */ async changePassword(data) { await this.apiClient.post(this.getEndpointUrl('changePassword'), data, { isProtected: true }); } /** * Initiates the password reset process * @param data - Email address for password reset */ async forgotPassword(data) { try { await this.apiClient.post(this.getEndpointUrl('forgotPassword'), data, { skipAuth: true, isProtected: false, }); } catch (error) { throw this.handleError(error); } } /** * Completes the password reset process * @param data - Reset password data including token and new password */ async resetPassword(data) { try { await this.apiClient.post(this.getEndpointUrl('resetPassword'), data, { skipAuth: true, isProtected: false, }); } catch (error) { throw this.handleError(error); } } /** * Verifies email with token * @param token - Email verification token */ async verifyEmail(token) { await this.apiClient.post(this.getEndpointUrl('verifyEmail'), { token }, { skipAuth: true, isProtected: false }); } /** * Updates user profile * @param data - Updated user data * @returns Updated user info */ async updateProfile(data) { const response = await this.apiClient.post(this.getEndpointUrl('updateProfile'), data, { isProtected: true, }); this.setStorageItem('user', response); return response; } /** * Gets current user data * @returns Current user info or null if not authenticated */ getUser() { const user = this.getStorageItem('user'); const data = typeof user === 'string' ? JSON.parse(user) : user; return user ? data : null; } /** * Gets remember user session option * @returns remember user session option or null */ getRememberUserSessionOption() { const rememberOption = this.getStorageItem('remember'); const data = typeof rememberOption === 'string' ? JSON.parse(rememberOption) : rememberOption; return rememberOption ? data : null; } /** * Validates current session * @returns Whether the session is valid */ async validateSession() { try { await this.apiClient.post(this.getEndpointUrl('validateToken'), undefined, { skipRefresh: true, isProtected: true, }); return true; } catch { return false; } } /** * Gets the URL for an endpoint */ getEndpointUrl(endpoint) { return this.endpoints[endpoint]; } /** * Initializes automatic token refresh */ initializeAutoRefresh() { const rememberSession = this.getRememberUserSessionOption(); this.stopAutoRefresh(); const scheduleNextCheck = () => { const { expiresAt } = this.tokenManager.getAccessToken(); if (!expiresAt) { this.clearAuth(); return; } const now = Date.now(); const timeUntilExpiry = expiresAt - now; const timeUntilRefresh = timeUntilExpiry - this.options.refreshThresholdMs; if (timeUntilRefresh <= 0) { if (rememberSession) { this.refreshTokens().then((success) => { if (success) { scheduleNextCheck(); } }); } else { this.clearAuth(); } return; } // If more than 5 minutes until refresh, check every minute // Otherwise, check more frequently as we get closer to expiry const nextCheckInterval = timeUntilRefresh > 5 * 60 * 1000 ? 60 * 1000 // check every minute : Math.max(1000, Math.min(timeUntilRefresh / 10, 30 * 1000)); // check between 1-30 seconds this.intervalRef.current = setTimeout(scheduleNextCheck, nextCheckInterval); }; scheduleNextCheck(); } /** * Stops automatic token refresh */ stopAutoRefresh() { if (this.intervalRef.current) { clearInterval(this.intervalRef.current); } } /** * Clears all authentication data */ clearAuth() { this.tokenManager.clearAllTokens(); this.clearStorage(); this.refreshAttempts = 0; this.stopAutoRefresh(); this.handleRedirectToLoginPage(); } /** * Storage utility methods */ getStorageKey(key) { return `${this.storageConfig.prefix}${key}`; } setStorageItem(key, value) { if (typeof window === 'undefined') return; localStorage.setItem(this.getStorageKey(key), typeof value === 'string' ? value : JSON.stringify(value)); } getStorageItem(key) { if (typeof window === 'undefined') return null; const item = localStorage.getItem(this.getStorageKey(key)); if (!item) return null; try { return JSON.parse(item); } catch { return item; } } clearStorage() { if (typeof window === 'undefined') return; this.storageConfig.keys.forEach((key) => { localStorage.removeItem(this.getStorageKey(key)); }); } /** * Error handler */ handleError(error) { if (error instanceof Error) { return { code: 'UNKNOWN_ERROR', message: error.message, }; } return { code: 'UNKNOWN_ERROR', message: 'An unknown error occurred', }; } } /** * Factory function to create an AuthService instance * @param config - Basic service configuration * @param tokenManager * @param apiClient - Shared API client instance * @returns Configured AuthService instance */ export function createAuthService(config, tokenManager, apiClient) { return new AuthService({ ...config, tokenManager, apiClient, }); }