@pagamio/frontend-commons-lib
Version:
Pagamio library for Frontend reusable components like the form engine and table container
476 lines (475 loc) • 16 kB
JavaScript
/**
* @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,
});
}