17 KiB
Auth Module
Overview
The Auth module provides JWT-based authentication with access and refresh tokens. It handles user registration, login, token refresh, and logout operations with comprehensive security features including password policy enforcement, rate limiting, and user enumeration prevention.
Key Features:
- JWT access tokens (15-minute expiry)
- Refresh token rotation with atomic transactions
- Password policy enforcement (12+ characters, complexity requirements)
- Rate limiting (10 requests/minute per IP)
- User enumeration prevention
- Account status validation (ACTIVE, SUSPENDED, BANNED)
- Temporary user expiration handling
- Prometheus metrics integration
File Paths
| File | Purpose |
|---|---|
api/src/modules/auth/auth.routes.ts |
Express router with 5 endpoints |
api/src/modules/auth/auth.service.ts |
Authentication business logic |
api/src/modules/auth/auth.schemas.ts |
Zod validation schemas |
Database Models
User Model
model User {
id String @id @default(cuid())
email String @unique
password String
name String?
phone String?
role UserRole @default(USER)
status UserStatus @default(ACTIVE)
permissions Json?
createdVia String? @default("web")
emailVerified Boolean @default(false)
expiresAt DateTime? // For TEMP users
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
refreshTokens RefreshToken[]
}
enum UserRole {
SUPER_ADMIN
INFLUENCE_ADMIN
MAP_ADMIN
USER
TEMP
}
enum UserStatus {
ACTIVE
SUSPENDED
BANNED
}
RefreshToken Model
model RefreshToken {
id String @id @default(cuid())
token String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
}
API Endpoints
| Method | Path | Auth | Rate Limit | Description |
|---|---|---|---|---|
| POST | /api/auth/login |
None | 10/min | Authenticate user with email/password |
| POST | /api/auth/register |
None | 10/min | Create new user account |
| POST | /api/auth/refresh |
None | 10/min | Refresh access token |
| POST | /api/auth/logout |
None | 10/min | Invalidate refresh token |
| GET | /api/auth/me |
Required | None | Get current user profile |
Endpoint Details
POST /api/auth/login
Authenticate user with email and password.
Request Body:
{
"email": "user@example.com",
"password": "SecurePass123"
}
Response (200 OK):
{
"user": {
"id": "clx1234567890",
"email": "user@example.com",
"name": "John Doe",
"phone": null,
"role": "USER",
"status": "ACTIVE",
"permissions": null,
"createdVia": "web",
"emailVerified": false,
"expiresAt": null,
"lastLoginAt": "2026-02-11T12:00:00.000Z",
"createdAt": "2026-02-01T12:00:00.000Z",
"updatedAt": "2026-02-11T12:00:00.000Z"
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Error Responses:
401 Unauthorized: Invalid email or password (prevents user enumeration)403 Forbidden: Account is suspended/banned or expired429 Too Many Requests: Rate limit exceeded
Implementation:
router.post(
'/login',
authRateLimit,
validate(loginSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.login(req.body.email, req.body.password);
res.json(result);
} catch (err) {
next(err);
}
}
);
Security Features:
- User Enumeration Prevention: Same error message for invalid email or password
- Account Status Validation: Checks ACTIVE, SUSPENDED, BANNED, expired states
- Login Metrics: Records success/failure for monitoring
- Last Login Tracking: Updates
lastLoginAttimestamp - Password Comparison: Uses bcrypt with 12 salt rounds
POST /api/auth/register
Create a new user account. Public endpoint restricted to USER role.
Request Body:
{
"email": "newuser@example.com",
"password": "SecurePass123",
"name": "Jane Smith",
"phone": "+1234567890"
}
Response (201 Created):
{
"user": {
"id": "clx0987654321",
"email": "newuser@example.com",
"name": "Jane Smith",
"phone": "+1234567890",
"role": "USER",
"status": "ACTIVE",
"permissions": null,
"createdVia": "web",
"emailVerified": false,
"expiresAt": null,
"lastLoginAt": null,
"createdAt": "2026-02-11T12:00:00.000Z",
"updatedAt": "2026-02-11T12:00:00.000Z"
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Error Responses:
409 Conflict: Email already registered400 Bad Request: Password doesn't meet complexity requirements429 Too Many Requests: Rate limit exceeded
Password Policy:
password: z.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one digit')
Security Notes:
- Role is always set to
USERserver-side (not user-controllable) - Password hashed with bcrypt (12 salt rounds)
- Immediately issues access + refresh tokens (auto-login after registration)
POST /api/auth/refresh
Refresh access token using a valid refresh token. Implements token rotation for security.
Request Body:
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Response (200 OK):
{
"user": {
"id": "clx1234567890",
"email": "user@example.com",
"name": "John Doe",
"role": "USER",
"status": "ACTIVE",
...
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Error Responses:
401 Unauthorized: Invalid, expired, or not found refresh token
Token Rotation Flow:
// Atomic transaction ensures no race condition
const tokens = await prisma.$transaction(async (tx) => {
// 1. Delete old refresh token
await tx.refreshToken.delete({ where: { id: stored.id } });
// 2. Generate new token pair
const accessToken = this.generateAccessToken(stored.user);
const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRY,
});
// 3. Store new refresh token
await tx.refreshToken.create({
data: {
token: refreshToken,
userId: stored.user.id,
expiresAt: new Date(decoded.exp * 1000),
},
});
return { accessToken, refreshToken };
});
Security Features:
- Atomic Rotation: Old token deleted and new token created in single transaction
- Expiration Check: Validates refresh token hasn't expired
- Database Validation: Checks token exists in database (prevents replay attacks)
- Automatic Cleanup: Expired tokens deleted on access attempt
POST /api/auth/logout
Invalidate a refresh token.
Request Body:
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Response (200 OK):
{
"message": "Logged out"
}
Implementation:
async logout(refreshToken: string) {
await prisma.refreshToken.deleteMany({ where: { token: refreshToken } });
}
Notes:
- Uses
deleteMany(safe if token doesn't exist) - Client should discard access token immediately
- Access tokens remain valid until expiry (15 minutes)
GET /api/auth/me
Get current authenticated user's profile.
Request Headers:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Response (200 OK):
{
"id": "clx1234567890",
"email": "user@example.com",
"name": "John Doe",
"phone": null,
"role": "USER",
"status": "ACTIVE",
"permissions": null,
"createdVia": "web",
"emailVerified": false,
"lastLoginAt": "2026-02-11T12:00:00.000Z",
"createdAt": "2026-02-01T12:00:00.000Z",
"updatedAt": "2026-02-11T12:00:00.000Z"
}
Error Responses:
401 Unauthorized: Missing, invalid, or expired access token401 Unauthorized: User not found (prevents user enumeration - same code as invalid token)
Security Note:
Returns 401 instead of 404 when user not found to prevent user enumeration.
Service Functions
authService.login(email, password)
Purpose: Authenticate user and generate token pair.
Flow:
- Find user by email
- Compare password with bcrypt
- Validate account status (ACTIVE, not expired)
- Record login metrics
- Update
lastLoginAttimestamp - Generate access + refresh token pair
- Return user (without password) + tokens
Error Handling:
if (!user) {
recordLoginAttempt('failure');
throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
recordLoginAttempt('failure');
throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');
}
if (user.status !== UserStatus.ACTIVE) {
recordLoginAttempt('failure');
throw new AppError(403, `Account is ${user.status.toLowerCase()}`, 'ACCOUNT_INACTIVE');
}
authService.register(data)
Purpose: Create new user account with hashed password.
Flow:
- Check if email already exists
- Hash password with bcrypt (12 salt rounds)
- Create user with
USERrole - Generate token pair
- Return user (without password) + tokens
Implementation:
const hashedPassword = await bcrypt.hash(data.password, 12);
const user = await prisma.user.create({
data: {
email: data.email,
password: hashedPassword,
name: data.name,
phone: data.phone,
role: UserRole.USER, // Always USER for public registration
},
});
authService.refreshTokens(refreshToken)
Purpose: Rotate refresh token and issue new access token.
Security:
- Atomic transaction (delete old + create new)
- Validates token signature with
JWT_REFRESH_SECRET - Checks database for token existence
- Validates expiration timestamp
- Prevents replay attacks
authService.generateAccessToken(user)
Purpose: Create short-lived JWT for API authentication.
Token Payload:
interface TokenPayload {
id: string;
email: string;
role: UserRole;
}
Configuration:
- Secret:
JWT_ACCESS_SECRETenvironment variable - Expiry:
JWT_ACCESS_EXPIRY(default:15m) - Algorithm: HS256 (HMAC with SHA-256)
Usage:
const accessToken = authService.generateAccessToken(user);
// Returns: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
authService.generateRefreshToken(user)
Purpose: Create long-lived JWT and store in database.
Configuration:
- Secret:
JWT_REFRESH_SECRET(must differ from access secret) - Expiry:
JWT_REFRESH_EXPIRY(default:7d) - Storage: Database (RefreshToken table)
Implementation:
const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
});
const decoded = jwt.decode(token) as { exp: number };
const expiresAt = new Date(decoded.exp * 1000);
await prisma.refreshToken.create({
data: {
token,
userId: user.id,
expiresAt,
},
});
Code Examples
Complete Login Flow
// Client: Login request
const response = await axios.post('/api/auth/login', {
email: 'user@example.com',
password: 'SecurePass123'
});
const { user, accessToken, refreshToken } = response.data;
// Store tokens
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
// Use access token for API requests
axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
Token Refresh Flow
// Client: 401 interceptor for automatic token refresh
axios.interceptors.response.use(
response => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
// Redirect to login
window.location.href = '/login';
return Promise.reject(error);
}
try {
const { data } = await axios.post('/api/auth/refresh', { refreshToken });
// Update stored tokens
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
// Retry original request with new token
error.config.headers['Authorization'] = `Bearer ${data.accessToken}`;
return axios(error.config);
} catch (refreshError) {
// Refresh failed, redirect to login
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
Protected Route Middleware
// Server: Protect routes with authentication
import { authenticate } from '../../middleware/auth.middleware';
router.get('/protected', authenticate, async (req, res) => {
// req.user is populated by authenticate middleware
const userId = req.user!.id;
const userRole = req.user!.role;
res.json({ message: 'Authenticated!', userId, userRole });
});
Role-Based Access Control
import { requireRole } from '../../middleware/rbac.middleware';
import { UserRole } from '@prisma/client';
// Only SUPER_ADMIN can access
router.delete('/users/:id',
authenticate,
requireRole(UserRole.SUPER_ADMIN),
async (req, res) => {
// Delete user logic
}
);
// Multiple roles allowed
router.post('/campaigns',
authenticate,
requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN),
async (req, res) => {
// Create campaign logic
}
);
Environment Configuration
Required environment variables:
# JWT Access Token (15 minutes)
JWT_ACCESS_SECRET=<random-32-byte-hex>
JWT_ACCESS_EXPIRY=15m
# JWT Refresh Token (7 days)
JWT_REFRESH_SECRET=<different-random-32-byte-hex>
JWT_REFRESH_EXPIRY=7d
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/changemaker_v2
# Redis (for rate limiting)
REDIS_URL=redis://:password@localhost:6379
REDIS_PASSWORD=<redis-password>
Generate secrets:
# Generate random secrets (macOS/Linux)
openssl rand -hex 32 # For JWT_ACCESS_SECRET
openssl rand -hex 32 # For JWT_REFRESH_SECRET (must differ!)
Security Considerations
Password Policy
- Minimum length: 12 characters
- Complexity: Uppercase, lowercase, digit required
- Hashing: bcrypt with 12 salt rounds
- Enforcement: Schema-level validation (cannot be bypassed)
Rate Limiting
// 10 requests per minute per IP
export const authRateLimit = rateLimit({
windowMs: 60 * 1000,
max: 10,
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
store: new RedisStore({
client: redis,
prefix: 'rl:auth:',
}),
});
User Enumeration Prevention
- Login/register errors don't reveal if email exists
/api/auth/mereturns401(not404) when user not found- Consistent error messages and response times
Token Security
- Access tokens: Short-lived (15 min), stored in memory
- Refresh tokens: Long-lived (7 days), stored in database + httpOnly cookie
- Rotation: Refresh tokens rotated on each use (atomic transaction)
- Secrets: Access and refresh use different secrets (prevents cross-contamination)
- Expiration: Automatic cleanup of expired tokens
Database Security
- Passwords never returned in API responses (excluded via
selector destructuring) - Refresh tokens cascade deleted when user deleted
- Unique constraint on
emailprevents duplicates - Foreign key constraints ensure referential integrity
Related Documentation
- Architecture: Authentication - Auth flow diagrams
- Middleware: Auth - JWT verification middleware
- Middleware: RBAC - Role-based access control
- Middleware: Rate Limit - Rate limiting configuration
- Frontend: Auth Store - Zustand auth state management
- API Reference: Auth - Complete endpoint reference
- User Guide: Admin - Managing user accounts
- Security Audit - Feb 2026 security review