@profullstack/auth-system
Version:
Flexible authentication system with user registration, login/logout, password reset, and session management
655 lines (572 loc) • 20.7 kB
JavaScript
/**
* @profullstack/auth-system
*
* A flexible authentication system with user registration, login/logout,
* password reset, and session management
*/
import { MemoryAdapter } from './adapters/memory.js';
import { JwtAdapter } from './adapters/jwt.js';
import { createPasswordUtils } from './utils/password.js';
import { createTokenUtils } from './utils/token.js';
import { createValidationUtils } from './utils/validation.js';
/**
* Authentication System
*/
class AuthSystem {
/**
* Create a new Authentication System
* @param {Object} options - Configuration options
* @param {Object} options.adapter - Storage adapter (defaults to in-memory)
* @param {Object} options.tokenOptions - Token configuration
* @param {number} options.tokenOptions.accessTokenExpiry - Access token expiry in seconds (default: 1 hour)
* @param {number} options.tokenOptions.refreshTokenExpiry - Refresh token expiry in seconds (default: 7 days)
* @param {string} options.tokenOptions.secret - Secret for signing tokens
* @param {Object} options.passwordOptions - Password configuration
* @param {number} options.passwordOptions.minLength - Minimum password length (default: 8)
* @param {boolean} options.passwordOptions.requireUppercase - Require uppercase letters (default: true)
* @param {boolean} options.passwordOptions.requireLowercase - Require lowercase letters (default: true)
* @param {boolean} options.passwordOptions.requireNumbers - Require numbers (default: true)
* @param {boolean} options.passwordOptions.requireSpecialChars - Require special characters (default: false)
* @param {Object} options.emailOptions - Email configuration
* @param {Function} options.emailOptions.sendEmail - Function to send emails
* @param {string} options.emailOptions.fromEmail - From email address
* @param {string} options.emailOptions.resetPasswordTemplate - Reset password email template
* @param {string} options.emailOptions.verificationTemplate - Email verification template
*/
constructor(options = {}) {
// Set up adapter
this.adapter = options.adapter || new MemoryAdapter();
// Set up token utilities
this.tokenUtils = createTokenUtils({
accessTokenExpiry: options.tokenOptions?.accessTokenExpiry || 3600, // 1 hour
refreshTokenExpiry: options.tokenOptions?.refreshTokenExpiry || 604800, // 7 days
secret: options.tokenOptions?.secret || 'default-secret-change-me'
});
// Set up password utilities
this.passwordUtils = createPasswordUtils({
minLength: options.passwordOptions?.minLength || 8,
requireUppercase: options.passwordOptions?.requireUppercase !== false,
requireLowercase: options.passwordOptions?.requireLowercase !== false,
requireNumbers: options.passwordOptions?.requireNumbers !== false,
requireSpecialChars: options.passwordOptions?.requireSpecialChars || false
});
// Set up validation utilities
this.validationUtils = createValidationUtils();
// Set up email options
this.emailOptions = {
sendEmail: options.emailOptions?.sendEmail || null,
fromEmail: options.emailOptions?.fromEmail || 'noreply@example.com',
resetPasswordTemplate: options.emailOptions?.resetPasswordTemplate || null,
verificationTemplate: options.emailOptions?.verificationTemplate || null
};
// Bind methods to ensure correct 'this' context
this.register = this.register.bind(this);
this.login = this.login.bind(this);
this.refreshToken = this.refreshToken.bind(this);
this.resetPassword = this.resetPassword.bind(this);
this.resetPasswordConfirm = this.resetPasswordConfirm.bind(this);
this.verifyEmail = this.verifyEmail.bind(this);
this.changePassword = this.changePassword.bind(this);
this.updateProfile = this.updateProfile.bind(this);
this.getProfile = this.getProfile.bind(this);
this.validateToken = this.validateToken.bind(this);
this.logout = this.logout.bind(this);
this.middleware = this.middleware.bind(this);
}
/**
* Register a new user
* @param {Object} userData - User data
* @param {string} userData.email - User email
* @param {string} userData.password - User password
* @param {Object} userData.profile - User profile data
* @param {boolean} userData.autoVerify - Auto-verify email (default: false)
* @returns {Promise<Object>} - Registration result
*/
async register(userData) {
try {
const { email, password, profile = {}, autoVerify = false } = userData;
// Validate email
if (!email || !this.validationUtils.isValidEmail(email)) {
throw new Error('Invalid email address');
}
// Validate password
const passwordValidation = this.passwordUtils.validatePassword(password);
if (!passwordValidation.valid) {
throw new Error(`Invalid password: ${passwordValidation.message}`);
}
// Check if user already exists
const existingUser = await this.adapter.getUserByEmail(email);
if (existingUser) {
throw new Error('User with this email already exists');
}
// Hash password
const hashedPassword = await this.passwordUtils.hashPassword(password);
// Create user
const user = await this.adapter.createUser({
email,
password: hashedPassword,
profile,
emailVerified: autoVerify,
createdAt: new Date().toISOString()
});
// Generate verification token if not auto-verified
if (!autoVerify && this.emailOptions.sendEmail) {
const verificationToken = await this.tokenUtils.generateEmailVerificationToken(user.id);
// Send verification email
await this._sendVerificationEmail(email, verificationToken);
}
// Generate tokens if auto-verified
let tokens = null;
if (autoVerify) {
tokens = await this.tokenUtils.generateTokens(user.id);
}
// Return user data (without password) and tokens if auto-verified
return {
success: true,
message: autoVerify ? 'User registered successfully' : 'User registered successfully. Please verify your email.',
user: this._sanitizeUser(user),
...(tokens && { tokens })
};
} catch (error) {
throw error;
}
}
/**
* Login a user
* @param {Object} credentials - Login credentials
* @param {string} credentials.email - User email
* @param {string} credentials.password - User password
* @returns {Promise<Object>} - Login result
*/
async login(credentials) {
try {
const { email, password } = credentials;
// Validate email
if (!email || !this.validationUtils.isValidEmail(email)) {
throw new Error('Invalid email address');
}
// Get user by email
const user = await this.adapter.getUserByEmail(email);
if (!user) {
throw new Error('Invalid email or password');
}
// Verify password
const passwordValid = await this.passwordUtils.verifyPassword(password, user.password);
if (!passwordValid) {
throw new Error('Invalid email or password');
}
// Check if email is verified
if (!user.emailVerified) {
throw new Error('Email not verified. Please verify your email before logging in.');
}
// Generate tokens
const tokens = await this.tokenUtils.generateTokens(user.id);
// Update last login timestamp
await this.adapter.updateUser(user.id, {
lastLoginAt: new Date().toISOString()
});
// Return user data (without password) and tokens
return {
success: true,
message: 'Login successful',
user: this._sanitizeUser(user),
tokens
};
} catch (error) {
throw error;
}
}
/**
* Refresh an access token using a refresh token
* @param {string} refreshToken - Refresh token
* @returns {Promise<Object>} - New tokens
*/
async refreshToken(refreshToken) {
try {
// Verify refresh token
const payload = await this.tokenUtils.verifyRefreshToken(refreshToken);
if (!payload) {
throw new Error('Invalid refresh token');
}
// Get user by ID
const user = await this.adapter.getUserById(payload.userId);
if (!user) {
throw new Error('User not found');
}
// Generate new tokens
const tokens = await this.tokenUtils.generateTokens(user.id);
// Return new tokens
return {
success: true,
message: 'Token refreshed successfully',
tokens
};
} catch (error) {
throw error;
}
}
/**
* Request a password reset
* @param {string} email - User email
* @returns {Promise<Object>} - Password reset result
*/
async resetPassword(email) {
try {
// Validate email
if (!email || !this.validationUtils.isValidEmail(email)) {
throw new Error('Invalid email address');
}
// Get user by email
const user = await this.adapter.getUserByEmail(email);
if (!user) {
// Don't reveal that the user doesn't exist
return {
success: true,
message: 'If your email is registered, you will receive a password reset link.'
};
}
// Generate password reset token
const resetToken = await this.tokenUtils.generatePasswordResetToken(user.id);
// Send password reset email
if (this.emailOptions.sendEmail) {
await this._sendPasswordResetEmail(email, resetToken);
}
// Return success
return {
success: true,
message: 'If your email is registered, you will receive a password reset link.',
...(process.env.NODE_ENV === 'development' && { resetToken })
};
} catch (error) {
throw error;
}
}
/**
* Confirm a password reset
* @param {Object} resetData - Password reset data
* @param {string} resetData.token - Password reset token
* @param {string} resetData.password - New password
* @returns {Promise<Object>} - Password reset result
*/
async resetPasswordConfirm(resetData) {
try {
const { token, password } = resetData;
// Verify reset token
const payload = await this.tokenUtils.verifyPasswordResetToken(token);
if (!payload) {
throw new Error('Invalid or expired password reset token');
}
// Validate password
const passwordValidation = this.passwordUtils.validatePassword(password);
if (!passwordValidation.valid) {
throw new Error(`Invalid password: ${passwordValidation.message}`);
}
// Get user by ID
const user = await this.adapter.getUserById(payload.userId);
if (!user) {
throw new Error('User not found');
}
// Hash new password
const hashedPassword = await this.passwordUtils.hashPassword(password);
// Update user password
await this.adapter.updateUser(user.id, {
password: hashedPassword,
updatedAt: new Date().toISOString()
});
// Return success
return {
success: true,
message: 'Password reset successfully'
};
} catch (error) {
throw error;
}
}
/**
* Verify a user's email
* @param {string} token - Email verification token
* @returns {Promise<Object>} - Email verification result
*/
async verifyEmail(token) {
try {
// Verify email verification token
const payload = await this.tokenUtils.verifyEmailVerificationToken(token);
if (!payload) {
throw new Error('Invalid or expired email verification token');
}
// Get user by ID
const user = await this.adapter.getUserById(payload.userId);
if (!user) {
throw new Error('User not found');
}
// Update user email verification status
await this.adapter.updateUser(user.id, {
emailVerified: true,
updatedAt: new Date().toISOString()
});
// Generate tokens
const tokens = await this.tokenUtils.generateTokens(user.id);
// Return success
return {
success: true,
message: 'Email verified successfully',
user: this._sanitizeUser(user),
tokens
};
} catch (error) {
throw error;
}
}
/**
* Change a user's password
* @param {Object} passwordData - Password change data
* @param {string} passwordData.userId - User ID
* @param {string} passwordData.currentPassword - Current password
* @param {string} passwordData.newPassword - New password
* @returns {Promise<Object>} - Password change result
*/
async changePassword(passwordData) {
try {
const { userId, currentPassword, newPassword } = passwordData;
// Get user by ID
const user = await this.adapter.getUserById(userId);
if (!user) {
throw new Error('User not found');
}
// Verify current password
const passwordValid = await this.passwordUtils.verifyPassword(currentPassword, user.password);
if (!passwordValid) {
throw new Error('Current password is incorrect');
}
// Validate new password
const passwordValidation = this.passwordUtils.validatePassword(newPassword);
if (!passwordValidation.valid) {
throw new Error(`Invalid password: ${passwordValidation.message}`);
}
// Hash new password
const hashedPassword = await this.passwordUtils.hashPassword(newPassword);
// Update user password
await this.adapter.updateUser(user.id, {
password: hashedPassword,
updatedAt: new Date().toISOString()
});
// Return success
return {
success: true,
message: 'Password changed successfully'
};
} catch (error) {
throw error;
}
}
/**
* Update a user's profile
* @param {Object} profileData - Profile update data
* @param {string} profileData.userId - User ID
* @param {Object} profileData.profile - Profile data
* @returns {Promise<Object>} - Profile update result
*/
async updateProfile(profileData) {
try {
const { userId, profile } = profileData;
// Get user by ID
const user = await this.adapter.getUserById(userId);
if (!user) {
throw new Error('User not found');
}
// Update user profile
await this.adapter.updateUser(userId, {
profile: { ...user.profile, ...profile },
updatedAt: new Date().toISOString()
});
// Get updated user
const updatedUser = await this.adapter.getUserById(userId);
// Return success
return {
success: true,
message: 'Profile updated successfully',
user: this._sanitizeUser(updatedUser)
};
} catch (error) {
throw error;
}
}
/**
* Get a user's profile
* @param {string} userId - User ID
* @returns {Promise<Object>} - User profile
*/
async getProfile(userId) {
try {
// Get user by ID
const user = await this.adapter.getUserById(userId);
if (!user) {
throw new Error('User not found');
}
// Return user data (without password)
return {
success: true,
user: this._sanitizeUser(user)
};
} catch (error) {
throw error;
}
}
/**
* Validate an access token
* @param {string} token - Access token
* @returns {Promise<Object|null>} - Token payload if valid, null otherwise
*/
async validateToken(token) {
try {
// Verify access token
const payload = await this.tokenUtils.verifyAccessToken(token);
if (!payload) {
return null;
}
// Get user by ID
const user = await this.adapter.getUserById(payload.userId);
if (!user) {
return null;
}
// Return user data (without password)
return {
userId: user.id,
email: user.email,
profile: user.profile,
emailVerified: user.emailVerified
};
} catch (error) {
return null;
}
}
/**
* Logout a user
* @param {string} refreshToken - Refresh token
* @returns {Promise<Object>} - Logout result
*/
async logout(refreshToken) {
try {
// Invalidate refresh token
await this.tokenUtils.invalidateRefreshToken(refreshToken);
// Return success
return {
success: true,
message: 'Logout successful'
};
} catch (error) {
throw error;
}
}
/**
* Authentication middleware for Express/Connect/Hono
* @returns {Function} - Middleware function
*/
middleware() {
return async (req, res, next) => {
try {
// Get token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' });
}
const token = authHeader.substring(7);
// Validate token
const user = await this.validateToken(token);
if (!user) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Set user in request
req.user = user;
// Continue to next middleware
next();
} catch (error) {
return res.status(401).json({ error: 'Unauthorized' });
}
};
}
/**
* Send a password reset email
* @private
* @param {string} email - User email
* @param {string} token - Password reset token
* @returns {Promise<void>}
*/
async _sendPasswordResetEmail(email, token) {
if (!this.emailOptions.sendEmail) {
return;
}
// Use custom template if provided, otherwise use default
const template = this.emailOptions.resetPasswordTemplate || {
subject: 'Password Reset',
text: `Click the link below to reset your password:\n\n${token}`,
html: `<p>Click the link below to reset your password:</p><p><a href="${token}">${token}</a></p>`
};
// Send email
await this.emailOptions.sendEmail({
to: email,
from: this.emailOptions.fromEmail,
subject: template.subject,
text: template.text,
html: template.html
});
}
/**
* Send an email verification email
* @private
* @param {string} email - User email
* @param {string} token - Email verification token
* @returns {Promise<void>}
*/
async _sendVerificationEmail(email, token) {
if (!this.emailOptions.sendEmail) {
return;
}
// Use custom template if provided, otherwise use default
const template = this.emailOptions.verificationTemplate || {
subject: 'Email Verification',
text: `Click the link below to verify your email:\n\n${token}`,
html: `<p>Click the link below to verify your email:</p><p><a href="${token}">${token}</a></p>`
};
// Send email
await this.emailOptions.sendEmail({
to: email,
from: this.emailOptions.fromEmail,
subject: template.subject,
text: template.text,
html: template.html
});
}
/**
* Remove sensitive data from user object
* @private
* @param {Object} user - User object
* @returns {Object} - Sanitized user object
*/
_sanitizeUser(user) {
const { password, ...sanitizedUser } = user;
return sanitizedUser;
}
}
// Create adapters
export { MemoryAdapter } from './adapters/memory.js';
export { JwtAdapter } from './adapters/jwt.js';
export { SupabaseAdapter } from './adapters/supabase.js';
export { MySQLAdapter } from './adapters/mysql.js';
export { PostgresAdapter } from './adapters/postgres.js';
export { MongoDBAdapter } from './adapters/mongodb.js';
export { PocketBaseAdapter } from './adapters/pocketbase.js';
// Create utilities
export { createPasswordUtils } from './utils/password.js';
export { createTokenUtils } from './utils/token.js';
export { createValidationUtils } from './utils/validation.js';
// Export the AuthSystem class
export { AuthSystem };
// Export a factory function for convenience
export function createAuthSystem(options = {}) {
return new AuthSystem(options);
}
// Import and export AuthClient for browser integration
export { AuthClient } from './client/auth-client.js';
// Default export
export default createAuthSystem;