Initial commit: Complete NodeJS-native setup

- Migrated from Python pre-commit to NodeJS-native solution
- Reorganized documentation structure
- Set up Husky + lint-staged for efficient pre-commit hooks
- Fixed Dockerfile healthcheck issue
- Added comprehensive documentation index
This commit is contained in:
William Valentin
2025-09-06 01:42:48 -07:00
commit e48adbcb00
159 changed files with 24405 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
import { authService } from '../auth.service';
import { AccountStatus } from '../auth.constants';
import { User } from '../../../types';
// Helper to clear localStorage and reset the mock DB before each test
beforeEach(() => {
// Clear all localStorage keys used by dbService and authService
Object.keys(localStorage).forEach(key => localStorage.removeItem(key));
});
describe('Authentication Integration Tests', () => {
const username = 'testuser';
const password = 'Passw0rd!';
const email = 'testuser@example.com';
test('User registration creates a pending account', async () => {
const result = await authService.register(email, password, username);
expect(result).toBeDefined();
expect(result.user.username).toBe(username);
expect(result.user.email).toBe(email);
expect(result.user.status).toBe(AccountStatus.PENDING);
expect(result.user.emailVerified).toBeFalsy();
});
test('Login fails for unverified (pending) account', async () => {
await expect(authService.login({ email, password })).rejects.toThrow();
});
test('Email verification activates the account', async () => {
// Register a user first to get the verification token
const result = await authService.register(email, password, username);
const verificationToken = result.verificationToken.token;
const verifiedUser = await authService.verifyEmail(verificationToken);
expect(verifiedUser.status).toBe(AccountStatus.ACTIVE);
expect(verifiedUser.emailVerified).toBeTruthy();
});
test('Login succeeds after email verification', async () => {
const tokens = await authService.login({ email, password });
expect(tokens).toBeDefined();
expect(tokens.accessToken).toBeTruthy();
expect(tokens.refreshToken).toBeTruthy();
});
test('OAuth flow registers or logs in a user', async () => {
const oauthEmail = 'oauthuser@example.com';
const oauthName = 'OAuth User';
const result = await authService.loginWithOAuth('google', {
email: oauthEmail,
username: oauthName,
});
expect(result).toBeDefined();
expect(result.user.email).toBe(oauthEmail);
// OAuth users should be active immediately
expect(result.user.status).toBe(AccountStatus.ACTIVE);
expect(result.user.emailVerified).toBeTruthy();
});
});

View File

@@ -0,0 +1,59 @@
import { EmailVerificationService } from '../emailVerification.service';
import { dbService } from '../../couchdb.factory';
jest.mock('../../couchdb.factory');
jest.mock('../../email');
describe('EmailVerificationService', () => {
let emailVerificationService: EmailVerificationService;
beforeEach(() => {
emailVerificationService = new EmailVerificationService();
});
test('should generate and validate verification token', async () => {
const user = {
_id: 'user1',
email: 'test@example.com',
username: 'testuser',
password: 'password',
};
const verificationToken =
await emailVerificationService.generateVerificationToken(user as any);
expect(verificationToken).toBeDefined();
expect(verificationToken.token).toBeDefined();
expect(verificationToken.expiresAt).toBeDefined();
const validatedUser =
await emailVerificationService.validateVerificationToken(
verificationToken.token
);
expect(validatedUser).toBeDefined();
expect(validatedUser!._id).toBe(user._id);
});
test('should not validate expired token', async () => {
const user = {
_id: 'user2',
email: 'test2@example.com',
username: 'testuser2',
password: 'password2',
};
const verificationToken =
await emailVerificationService.generateVerificationToken(user as any);
// Set expiresAt to past date
verificationToken.expiresAt = new Date(Date.now() - 1000 * 60 * 60 * 24);
const validatedUser =
await emailVerificationService.validateVerificationToken(
verificationToken.token
);
expect(validatedUser).toBeNull();
});
});

View File

@@ -0,0 +1,25 @@
// Client-side auth constants for demo/development purposes
// In production, these would be handled by a secure backend service
export const JWT_EXPIRES_IN = '1h';
export const REFRESH_TOKEN_EXPIRES_IN = '7d';
export const EMAIL_VERIFICATION_EXPIRES_IN = '24h';
// Mock secrets for frontend-only demo (NOT for production use)
export const JWT_SECRET = 'demo_jwt_secret_for_frontend_only';
export const REFRESH_TOKEN_SECRET = 'demo_refresh_secret_for_frontend_only';
export const EMAIL_VERIFICATION_SECRET =
'demo_email_verification_secret_for_frontend_only';
export enum AccountStatus {
PENDING = 'PENDING',
ACTIVE = 'ACTIVE',
SUSPENDED = 'SUSPENDED',
}
export interface AuthConfig {
jwtSecret: string;
jwtExpiresIn: string;
refreshTokenExpiresIn: string;
emailVerificationExpiresIn: string;
}

View File

@@ -0,0 +1,43 @@
import { NextFunction, Request, Response } from 'express';
/**
* Custom AuthError class that extends Error with HTTP status code
* Security: Provides consistent error handling for authentication issues
*/
export class AuthError extends Error {
statusCode: number;
constructor(message: string, statusCode: number = 401) {
super(message);
this.statusCode = statusCode;
this.name = 'AuthError';
}
}
/**
* Middleware to handle AuthError exceptions
* Security: Centralized error handling for authentication errors
*/
export const handleAuthError = (
err: Error,
req: Request,
res: Response,
next: NextFunction
) => {
if (err instanceof AuthError) {
return res.status(err.statusCode).json({
error: err.message,
statusCode: err.statusCode,
});
}
// Handle JWT verification errors
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Invalid or expired token',
statusCode: 401,
});
}
next(err);
};

View File

@@ -0,0 +1,48 @@
import { Request, Response, NextFunction } from 'express';
import * as jwt from 'jsonwebtoken';
import { JWT_SECRET } from './auth.constants';
import { AuthError, handleAuthError } from './auth.error';
// Security: JWT authentication middleware
export const authenticate = (
req: Request,
res: Response,
next: NextFunction
) => {
try {
// Security: Get token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new AuthError('No authentication token provided', 401);
}
const token = authHeader.split(' ')[1];
// Security: Verify JWT token
const decoded = jwt.verify(token, JWT_SECRET);
// Add user information to request
req.user = decoded;
next();
} catch (error) {
handleAuthError(error, req, res, next);
}
};
// Security: Role-based authorization middleware
export const authorize = (...allowedRoles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
// Security: Check if user exists in request
if (!req.user) {
throw new AuthError('Authentication required', 401);
}
// In a full implementation, we would check user roles
next();
} catch (error) {
handleAuthError(error, req, res, next);
}
};
};

View File

@@ -0,0 +1,244 @@
import { v4 as uuidv4 } from 'uuid';
import { dbService } from '../../services/couchdb.factory';
import { AccountStatus } from './auth.constants';
import { User } from '../../types';
import { AuthenticatedUser } from './auth.types';
import { EmailVerificationService } from './emailVerification.service';
const emailVerificationService = new EmailVerificationService();
const authService = {
async register(email: string, password: string, username?: string) {
try {
// Create user with password
const user = await dbService.createUserWithPassword(
email,
password,
username
);
// Generate and send verification token (in production)
const verificationToken =
await emailVerificationService.generateVerificationToken(
user as AuthenticatedUser
);
return { user, verificationToken };
} catch (error) {
if (error.message.includes('already exists')) {
throw new Error('An account with this email already exists');
}
throw error;
}
},
async login(input: { email: string; password: string }) {
console.log('🔐 Login attempt for:', input.email);
// Find user by email
const user = await dbService.findUserByEmail(input.email);
if (!user) {
console.log('❌ User not found for email:', input.email);
throw new Error('User not found');
}
console.log('👤 User found:', {
email: user.email,
hasPassword: !!user.password,
role: user.role,
status: user.status,
emailVerified: user.emailVerified,
});
// Check if user has a password (email-based account)
if (!user.password) {
console.log('❌ No password found - OAuth account');
throw new Error(
'This account was created with OAuth. Please use Google or GitHub to sign in.'
);
}
// Simple password verification (in production, use bcrypt)
console.log('🔍 Comparing passwords:', {
inputPassword: input.password,
storedPassword: user.password,
match: user.password === input.password,
});
if (user.password !== input.password) {
console.log('❌ Password mismatch');
throw new Error('Invalid password');
}
console.log('✅ Login successful for:', user.email);
// Return mock tokens for frontend compatibility
return {
user,
accessToken: 'mock_access_token_' + Date.now(),
refreshToken: 'mock_refresh_token_' + Date.now(),
};
},
async loginWithOAuth(
provider: 'google' | 'github',
userData: { email: string; username: string; avatar?: string }
) {
try {
// Try to find existing user by email
let user = await dbService.findUserByEmail(userData.email);
if (!user) {
// Create new user from OAuth data
user = await dbService.createUserFromOAuth(userData);
}
// Generate access tokens
return {
user,
accessToken: `oauth_${provider}_token_` + Date.now(),
refreshToken: `oauth_${provider}_refresh_` + Date.now(),
};
} catch (error) {
throw new Error(`OAuth login failed: ${error.message}`);
}
},
async verifyEmail(token: string) {
const user =
await emailVerificationService.validateVerificationToken(token);
if (!user) {
throw new Error('Invalid or expired verification token');
}
await emailVerificationService.markEmailVerified(user);
return user;
},
async changePassword(
userId: string,
currentPassword: string,
newPassword: string
) {
// Get user by ID
const user = await dbService.getUserById(userId);
if (!user) {
throw new Error('User not found');
}
// Check if user has a password (not OAuth user)
if (!user.password) {
throw new Error('Cannot change password for OAuth accounts');
}
// Verify current password
if (user.password !== currentPassword) {
throw new Error('Current password is incorrect');
}
// Validate new password
if (newPassword.length < 6) {
throw new Error('New password must be at least 6 characters long');
}
// Update password
const updatedUser = await dbService.changeUserPassword(userId, newPassword);
return {
user: updatedUser,
message: 'Password changed successfully',
};
},
async requestPasswordReset(email: string) {
const user = await dbService.findUserByEmail(email);
if (!user) {
// Don't reveal if email exists or not for security
return {
message:
'If an account with this email exists, a password reset link has been sent.',
};
}
if (!user.password) {
throw new Error('Cannot reset password for OAuth accounts');
}
// Generate reset token (similar to verification token)
const resetToken = uuidv4().replace(/-/g, '');
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1); // 1 hour expiry
// Store reset token (in production, save to database)
const resetTokens = JSON.parse(
localStorage.getItem('password_reset_tokens') || '[]'
);
resetTokens.push({
userId: user._id,
email: user.email,
token: resetToken,
expiresAt,
});
localStorage.setItem('password_reset_tokens', JSON.stringify(resetTokens));
// Send reset email
const emailSent = await emailVerificationService.sendPasswordResetEmail(
user.email!,
resetToken
);
return {
message:
'If an account with this email exists, a password reset link has been sent.',
emailSent,
};
},
async resetPassword(token: string, newPassword: string) {
// Get reset tokens
const resetTokens = JSON.parse(
localStorage.getItem('password_reset_tokens') || '[]'
);
const resetToken = resetTokens.find((t: any) => t.token === token);
if (!resetToken) {
throw new Error('Invalid or expired reset token');
}
// Check if token is expired
if (new Date() > new Date(resetToken.expiresAt)) {
throw new Error('Reset token has expired');
}
// Validate new password
if (newPassword.length < 6) {
throw new Error('Password must be at least 6 characters long');
}
// Update password
const updatedUser = await dbService.changeUserPassword(
resetToken.userId,
newPassword
);
// Remove used token
const filteredTokens = resetTokens.filter((t: any) => t.token !== token);
localStorage.setItem(
'password_reset_tokens',
JSON.stringify(filteredTokens)
);
return {
user: updatedUser,
message: 'Password reset successfully',
};
},
};
export { authService };
export default authService;

View File

@@ -0,0 +1,42 @@
import { User } from '../../types';
import { AccountStatus } from './auth.constants';
export interface RegisterInput {
username: string;
email: string;
password: string;
}
export interface LoginInput {
email: string;
password: string;
}
export interface AuthResponse {
user: User;
accessToken: string;
refreshToken: string;
}
export interface TokenPayload {
userId: string;
username: string;
}
export interface EmailVerificationToken {
userId: string;
email: string;
token: string;
expiresAt: Date;
}
export interface RefreshTokenPayload {
userId: string;
refreshToken: string;
}
export interface AuthenticatedUser extends User {
status: AccountStatus;
email?: string;
emailVerified?: boolean;
}

View File

@@ -0,0 +1,95 @@
import { v4 as uuidv4 } from 'uuid';
import { EmailVerificationToken, AuthenticatedUser } from './auth.types';
import { mailgunService } from '../mailgun.service';
import { AccountStatus } from './auth.constants';
const TOKEN_EXPIRY_HOURS = 24;
export class EmailVerificationService {
async generateVerificationToken(
user: AuthenticatedUser
): Promise<EmailVerificationToken> {
const token = uuidv4().replace(/-/g, ''); // Generate a random token using UUID
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + TOKEN_EXPIRY_HOURS);
const verificationToken: EmailVerificationToken = {
userId: user._id,
email: user.email || '',
token,
expiresAt,
};
// Store token in localStorage for demo (in production, save to database)
const tokens = JSON.parse(
localStorage.getItem('verification_tokens') || '[]'
);
tokens.push(verificationToken);
localStorage.setItem('verification_tokens', JSON.stringify(tokens));
// Send verification email via Mailgun
if (user.email) {
const emailSent = await mailgunService.sendVerificationEmail(
user.email,
token
);
if (!emailSent) {
console.warn('Failed to send verification email');
}
}
return verificationToken;
}
async validateVerificationToken(
token: string
): Promise<AuthenticatedUser | null> {
// Get tokens from localStorage
const tokens = JSON.parse(
localStorage.getItem('verification_tokens') || '[]'
);
const verificationToken = tokens.find(
(t: EmailVerificationToken) => t.token === token
);
if (!verificationToken) {
return null;
}
// Check if token is expired
if (new Date() > new Date(verificationToken.expiresAt)) {
return null;
}
// Find the user (in production, this would be a proper database lookup)
const { dbService } = await import('../couchdb');
const user = await dbService.findUserByEmail(verificationToken.email);
return user as AuthenticatedUser;
}
async markEmailVerified(user: AuthenticatedUser): Promise<void> {
// Update user in database
const { dbService } = await import('../couchdb');
const updatedUser = {
...user,
emailVerified: true,
status: AccountStatus.ACTIVE,
};
await dbService.updateUser(updatedUser);
// Remove used token
const tokens = JSON.parse(
localStorage.getItem('verification_tokens') || '[]'
);
const filteredTokens = tokens.filter(
(t: EmailVerificationToken) => t.userId !== user._id
);
localStorage.setItem('verification_tokens', JSON.stringify(filteredTokens));
}
async sendPasswordResetEmail(email: string, token: string): Promise<boolean> {
return mailgunService.sendPasswordResetEmail(email, token);
}
}

View File

@@ -0,0 +1,17 @@
import { EmailVerificationToken } from '../auth.types';
export const verificationEmailTemplate = (token: EmailVerificationToken) => {
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:5173';
const verificationLink = `${baseUrl}/verify-email?token=${token.token}`;
return `
<html>
<body>
<h1>Email Verification</h1>
<p>Please verify your email address by clicking the link below:</p>
<p><a href="${verificationLink}">${verificationLink}</a></p>
<p>This link will expire in 24 hours.</p>
</body>
</html>
`;
};