import * as crypto from 'crypto'; import { logError } from './Logger'; export interface VerificationToken { token: string; expiresAt: Date; createdAt: Date; } export interface PasswordResetToken { token: string; expiresAt: Date; createdAt: Date; } export class TokenService { private static readonly VERIFICATION_TOKEN_EXPIRES_HOURS = 24; private static readonly PASSWORD_RESET_TOKEN_EXPIRES_HOURS = 1; private static readonly TOKEN_LENGTH = 32; /** * Generate a secure random token * @param length - Length of the token in bytes (default: 32) * @returns Hexadecimal string token */ static generateSecureToken(length: number = TokenService.TOKEN_LENGTH): string { try { return crypto.randomBytes(length).toString('hex'); } catch (error) { logError('TokenService.generateSecureToken error', error instanceof Error ? error : new Error(String(error))); throw new Error('Failed to generate secure token'); } } /** * Generate email verification token with expiration * @returns VerificationToken object with token and expiration info */ static generateVerificationToken(): VerificationToken { try { const token = this.generateSecureToken(); const createdAt = new Date(); const expiresAt = new Date(createdAt.getTime() + (this.VERIFICATION_TOKEN_EXPIRES_HOURS * 60 * 60 * 1000)); return { token, createdAt, expiresAt }; } catch (error) { logError('TokenService.generateVerificationToken error', error instanceof Error ? error : new Error(String(error))); throw new Error('Failed to generate verification token'); } } /** * Generate password reset token with expiration * @returns PasswordResetToken object with token and expiration info */ static generatePasswordResetToken(): PasswordResetToken { try { const token = this.generateSecureToken(); const createdAt = new Date(); const expiresAt = new Date(createdAt.getTime() + (this.PASSWORD_RESET_TOKEN_EXPIRES_HOURS * 60 * 60 * 1000)); return { token, createdAt, expiresAt }; } catch (error) { logError('TokenService.generatePasswordResetToken error', error instanceof Error ? error : new Error(String(error))); throw new Error('Failed to generate password reset token'); } } /** * Check if a token has expired * @param expiresAt - Expiration date of the token * @returns True if token has expired, false otherwise */ static isTokenExpired(expiresAt: Date): boolean { try { return new Date() > expiresAt; } catch (error) { logError('TokenService.isTokenExpired error', error instanceof Error ? error : new Error(String(error))); return true; // Assume expired on error for security } } /** * Validate token format (basic validation) * @param token - Token to validate * @returns True if token format is valid, false otherwise */ static isValidTokenFormat(token: string): boolean { try { if (!token || typeof token !== 'string') { return false; } // Check if token is hexadecimal and has expected length const hexRegex = /^[a-f0-9]+$/i; const expectedLength = this.TOKEN_LENGTH * 2; // Each byte becomes 2 hex characters return hexRegex.test(token) && token.length === expectedLength; } catch (error) { logError('TokenService.isValidTokenFormat error', error instanceof Error ? error : new Error(String(error))); return false; } } /** * Generate a verification URL with token * @param baseUrl - Base URL of the application * @param token - Verification token * @returns Complete verification URL */ static generateVerificationUrl(baseUrl: string, token: string): string { try { // Remove trailing slash from baseUrl if present const cleanBaseUrl = baseUrl.replace(/\/$/, ''); return `${cleanBaseUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}`; } catch (error) { logError('TokenService.generateVerificationUrl error', error instanceof Error ? error : new Error(String(error))); throw new Error('Failed to generate verification URL'); } } /** * Generate a password reset URL with token * @param baseUrl - Base URL of the application * @param token - Password reset token * @returns Complete password reset URL */ static generatePasswordResetUrl(baseUrl: string, token: string): string { try { // Remove trailing slash from baseUrl if present const cleanBaseUrl = baseUrl.replace(/\/$/, ''); return `${cleanBaseUrl}/api/auth/reset-password?token=${encodeURIComponent(token)}`; } catch (error) { logError('TokenService.generatePasswordResetUrl error', error instanceof Error ? error : new Error(String(error))); throw new Error('Failed to generate password reset URL'); } } /** * Hash a token for secure storage in database * @param token - Plain text token to hash * @returns Hashed token */ static async hashToken(token: string): Promise { try { if (!token || typeof token !== 'string') { throw new Error('Token must be a non-empty string'); } return crypto.createHash('sha256').update(token).digest('hex'); } catch (error) { logError('TokenService.hashToken error', error instanceof Error ? error : new Error(String(error))); throw new Error('Failed to hash token'); } } /** * Verify a plain text token against a hashed token * @param plainToken - Plain text token to verify * @param hashedToken - Hashed token to compare against * @returns True if tokens match, false otherwise */ static async verifyToken(plainToken: string, hashedToken: string): Promise { try { if (!plainToken || !hashedToken) { return false; } const hashedPlainToken = await this.hashToken(plainToken); return hashedPlainToken === hashedToken; } catch (error) { logError('TokenService.verifyToken error', error instanceof Error ? error : new Error(String(error))); return false; } } /** * Get token expiration info in human-readable format * @param expiresAt - Expiration date * @returns Human-readable expiration info */ static getExpirationInfo(expiresAt: Date): { expired: boolean; timeLeft: string } { try { const now = new Date(); const expired = now > expiresAt; if (expired) { const timeAgo = Math.floor((now.getTime() - expiresAt.getTime()) / (1000 * 60)); return { expired: true, timeLeft: `Expired ${timeAgo} minute(s) ago` }; } const timeLeft = Math.floor((expiresAt.getTime() - now.getTime()) / (1000 * 60)); const hours = Math.floor(timeLeft / 60); const minutes = timeLeft % 60; let timeString = ''; if (hours > 0) { timeString = `${hours} hour(s)`; if (minutes > 0) { timeString += ` and ${minutes} minute(s)`; } } else { timeString = `${minutes} minute(s)`; } return { expired: false, timeLeft: `Expires in ${timeString}` }; } catch (error) { logError('TokenService.getExpirationInfo error', error instanceof Error ? error : new Error(String(error))); return { expired: true, timeLeft: 'Unable to determine expiration' }; } } }