import { sign, verify } from 'jsonwebtoken'; export interface EmailVerificationTokenPayload { userId: string; email: string; purpose: 'email-verification'; iat: number; } // In-memory token storage for validation (in production, consider Redis) const activeTokens = new Map(); /** * Generate a secure JWT token for email verification */ export async function generateEmailVerificationToken(userId: string, email: string): Promise { const runtimeConfig = useRuntimeConfig(); if (!runtimeConfig.jwtSecret) { throw new Error('JWT secret not configured'); } const payload: EmailVerificationTokenPayload = { userId, email: email.toLowerCase().trim(), purpose: 'email-verification', iat: Date.now() }; const token = sign(payload, runtimeConfig.jwtSecret, { expiresIn: '24h', issuer: 'monacousa-portal', audience: 'email-verification' }); // Store token metadata for additional validation activeTokens.set(token, payload); // Clean up expired tokens periodically setTimeout(() => { activeTokens.delete(token); }, 24 * 60 * 60 * 1000); // 24 hours console.log('[email-tokens] Generated verification token for user:', userId, 'email:', email); return token; } /** * Verify and decode an email verification token */ export async function verifyEmailToken(token: string): Promise<{ userId: string; email: string }> { const runtimeConfig = useRuntimeConfig(); if (!runtimeConfig.jwtSecret) { throw new Error('JWT secret not configured'); } if (!token) { throw new Error('Token is required'); } try { // Verify JWT signature and expiration const decoded = verify(token, runtimeConfig.jwtSecret, { issuer: 'monacousa-portal', audience: 'email-verification' }) as EmailVerificationTokenPayload; // Validate token purpose if (decoded.purpose !== 'email-verification') { throw new Error('Invalid token purpose'); } // Check if token exists in our active tokens (prevents replay attacks) const storedPayload = activeTokens.get(token); if (!storedPayload) { throw new Error('Token not found or already used'); } // Validate payload consistency if (storedPayload.userId !== decoded.userId || storedPayload.email !== decoded.email) { throw new Error('Token payload mismatch'); } // Remove token after successful verification (single use) activeTokens.delete(token); console.log('[email-tokens] Successfully verified token for user:', decoded.userId, 'email:', decoded.email); return { userId: decoded.userId, email: decoded.email }; } catch (error: any) { console.error('[email-tokens] Token verification failed:', error.message); // Provide user-friendly error messages if (error.name === 'TokenExpiredError') { throw new Error('Verification link has expired. Please request a new one.'); } else if (error.name === 'JsonWebTokenError') { throw new Error('Invalid verification link.'); } else { throw new Error(error.message || 'Token verification failed'); } } } /** * Check if a token is still valid without consuming it */ export async function isTokenValid(token: string): Promise { try { const runtimeConfig = useRuntimeConfig(); if (!runtimeConfig.jwtSecret || !token) { return false; } const decoded = verify(token, runtimeConfig.jwtSecret, { issuer: 'monacousa-portal', audience: 'email-verification' }) as EmailVerificationTokenPayload; return decoded.purpose === 'email-verification' && activeTokens.has(token); } catch (error) { return false; } } /** * Clean up expired tokens from memory */ export function cleanupExpiredTokens(): void { const now = Date.now(); const expirationTime = 24 * 60 * 60 * 1000; // 24 hours for (const [token, payload] of activeTokens.entries()) { if (now - payload.iat > expirationTime) { activeTokens.delete(token); } } console.log('[email-tokens] Cleaned up expired tokens. Active tokens:', activeTokens.size); } /** * Get statistics about active tokens */ export function getTokenStats(): { activeTokens: number; oldestToken: number | null } { const now = Date.now(); let oldestToken: number | null = null; for (const payload of activeTokens.values()) { if (oldestToken === null || payload.iat < oldestToken) { oldestToken = payload.iat; } } return { activeTokens: activeTokens.size, oldestToken: oldestToken ? Math.floor((now - oldestToken) / 1000 / 60) : null // minutes ago }; } // Periodic cleanup of expired tokens (every hour) setInterval(cleanupExpiredTokens, 60 * 60 * 1000);