/** * AuthService * * @description Centralized authentication service for ERP-Suite. * Moved from erp-core to shared-libs (P0-014). * * Features: * - Email/password login * - User registration with multi-tenancy support * - JWT token generation and refresh * - Password change * - Profile retrieval * * @example * ```typescript * import { AuthService, createAuthService } from '@erp-suite/core'; * * const authService = createAuthService({ * jwtSecret: process.env.JWT_SECRET, * jwtExpiresIn: '1h', * queryFn: myQueryFunction, * }); * * const result = await authService.login({ email, password }); * ``` */ import bcrypt from 'bcryptjs'; import jwt, { SignOptions } from 'jsonwebtoken'; /** * Login data transfer object */ export interface LoginDto { email: string; password: string; } /** * Registration data transfer object */ export interface RegisterDto { email: string; password: string; full_name?: string; firstName?: string; lastName?: string; tenant_id?: string; companyName?: string; } /** * JWT payload structure */ export interface JwtPayload { userId: string; tenantId: string; email: string; roles: string[]; iat?: number; exp?: number; } /** * Auth tokens response */ export interface AuthTokens { accessToken: string; refreshToken: string; expiresIn: string; } /** * User entity (without password) */ export interface AuthUser { id: string; tenant_id: string; email: string; full_name: string; firstName?: string; lastName?: string; status: string; role_codes?: string[]; created_at: Date; last_login_at?: Date; } /** * Internal user with password */ interface InternalUser extends AuthUser { password_hash: string; } /** * Login response */ export interface LoginResponse { user: AuthUser; tokens: AuthTokens; } /** * Query function type for database operations */ export type QueryFn = (sql: string, params: unknown[]) => Promise; export type QueryOneFn = (sql: string, params: unknown[]) => Promise; /** * Logger interface */ export interface AuthLogger { info: (message: string, meta?: Record) => void; error: (message: string, meta?: Record) => void; warn: (message: string, meta?: Record) => void; } /** * Auth service configuration */ export interface AuthServiceConfig { jwtSecret: string; jwtExpiresIn: string; jwtRefreshExpiresIn: string; queryOne: QueryOneFn; query: QueryFn; logger?: AuthLogger; } /** * Error types for auth operations */ export class AuthUnauthorizedError extends Error { constructor(message: string) { super(message); this.name = 'UnauthorizedError'; } } export class AuthValidationError extends Error { constructor(message: string) { super(message); this.name = 'ValidationError'; } } export class AuthNotFoundError extends Error { constructor(message: string) { super(message); this.name = 'NotFoundError'; } } /** * Transforms full_name to firstName/lastName */ export function splitFullName(fullName: string): { firstName: string; lastName: string } { const parts = (fullName || '').trim().split(/\s+/); if (parts.length === 0 || parts[0] === '') { return { firstName: '', lastName: '' }; } if (parts.length === 1) { return { firstName: parts[0], lastName: '' }; } const firstName = parts[0]; const lastName = parts.slice(1).join(' '); return { firstName, lastName }; } /** * Transforms firstName/lastName to full_name */ export function buildFullName( firstName?: string, lastName?: string, fullName?: string, ): string { if (fullName) return fullName.trim(); return `${firstName || ''} ${lastName || ''}`.trim(); } /** * Centralized Auth Service for ERP-Suite */ export class AuthService { private readonly config: AuthServiceConfig; private readonly logger: AuthLogger; constructor(config: AuthServiceConfig) { this.config = config; this.logger = config.logger || { info: console.log, error: console.error, warn: console.warn, }; } /** * Login with email/password */ async login(dto: LoginDto): Promise { const user = await this.config.queryOne( `SELECT u.*, array_agg(r.code) as role_codes FROM auth.users u LEFT JOIN auth.user_roles ur ON u.id = ur.user_id LEFT JOIN auth.roles r ON ur.role_id = r.id WHERE u.email = $1 AND u.status = 'active' GROUP BY u.id`, [dto.email.toLowerCase()], ); if (!user) { throw new AuthUnauthorizedError('Credenciales invalidas'); } const isValidPassword = await bcrypt.compare( dto.password, user.password_hash || '', ); if (!isValidPassword) { throw new AuthUnauthorizedError('Credenciales invalidas'); } // Update last login await this.config.query( 'UPDATE auth.users SET last_login_at = NOW() WHERE id = $1', [user.id], ); const tokens = this.generateTokens(user); const userResponse = this.formatUserResponse(user); this.logger.info('User logged in', { userId: user.id, email: user.email }); return { user: userResponse, tokens }; } /** * Register new user */ async register(dto: RegisterDto): Promise { const existingUser = await this.config.queryOne( 'SELECT id FROM auth.users WHERE email = $1', [dto.email.toLowerCase()], ); if (existingUser) { throw new AuthValidationError('El email ya esta registrado'); } const fullName = buildFullName(dto.firstName, dto.lastName, dto.full_name); const password_hash = await bcrypt.hash(dto.password, 10); const tenantId = dto.tenant_id || crypto.randomUUID(); const newUser = await this.config.queryOne( `INSERT INTO auth.users (tenant_id, email, password_hash, full_name, status, created_at) VALUES ($1, $2, $3, $4, 'active', NOW()) RETURNING *`, [tenantId, dto.email.toLowerCase(), password_hash, fullName], ); if (!newUser) { throw new Error('Error al crear usuario'); } const tokens = this.generateTokens(newUser); const userResponse = this.formatUserResponse(newUser); this.logger.info('User registered', { userId: newUser.id, email: newUser.email }); return { user: userResponse, tokens }; } /** * Refresh access token */ async refreshToken(refreshToken: string): Promise { try { const payload = jwt.verify( refreshToken, this.config.jwtSecret, ) as JwtPayload; const user = await this.config.queryOne( 'SELECT * FROM auth.users WHERE id = $1 AND status = $2', [payload.userId, 'active'], ); if (!user) { throw new AuthUnauthorizedError('Usuario no encontrado o inactivo'); } return this.generateTokens(user); } catch (error) { if (error instanceof jwt.TokenExpiredError) { throw new AuthUnauthorizedError('Refresh token expirado'); } throw new AuthUnauthorizedError('Refresh token invalido'); } } /** * Change user password */ async changePassword( userId: string, currentPassword: string, newPassword: string, ): Promise { const user = await this.config.queryOne( 'SELECT * FROM auth.users WHERE id = $1', [userId], ); if (!user) { throw new AuthNotFoundError('Usuario no encontrado'); } const isValidPassword = await bcrypt.compare( currentPassword, user.password_hash || '', ); if (!isValidPassword) { throw new AuthUnauthorizedError('Contrasena actual incorrecta'); } const newPasswordHash = await bcrypt.hash(newPassword, 10); await this.config.query( 'UPDATE auth.users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [newPasswordHash, userId], ); this.logger.info('Password changed', { userId }); } /** * Get user profile */ async getProfile(userId: string): Promise { const user = await this.config.queryOne( `SELECT u.*, array_agg(r.code) as role_codes FROM auth.users u LEFT JOIN auth.user_roles ur ON u.id = ur.user_id LEFT JOIN auth.roles r ON ur.role_id = r.id WHERE u.id = $1 GROUP BY u.id`, [userId], ); if (!user) { throw new AuthNotFoundError('Usuario no encontrado'); } return this.formatUserResponse(user); } /** * Verify JWT token */ verifyToken(token: string): JwtPayload { try { return jwt.verify(token, this.config.jwtSecret) as JwtPayload; } catch (error) { if (error instanceof jwt.TokenExpiredError) { throw new AuthUnauthorizedError('Token expirado'); } throw new AuthUnauthorizedError('Token invalido'); } } /** * Generate JWT tokens */ private generateTokens(user: InternalUser): AuthTokens { const payload: JwtPayload = { userId: user.id, tenantId: user.tenant_id, email: user.email, roles: user.role_codes || [], }; const accessToken = jwt.sign(payload, this.config.jwtSecret, { expiresIn: this.config.jwtExpiresIn, } as SignOptions); const refreshToken = jwt.sign(payload, this.config.jwtSecret, { expiresIn: this.config.jwtRefreshExpiresIn, } as SignOptions); return { accessToken, refreshToken, expiresIn: this.config.jwtExpiresIn, }; } /** * Format user for response (remove password_hash, add firstName/lastName) */ private formatUserResponse(user: InternalUser): AuthUser { const { firstName, lastName } = splitFullName(user.full_name); const { password_hash: _, ...userWithoutPassword } = user; return { ...userWithoutPassword, firstName, lastName, }; } } /** * Factory function to create AuthService instance */ export function createAuthService(config: AuthServiceConfig): AuthService { return new AuthService(config); }