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 expired
  • 429 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:

  1. User Enumeration Prevention: Same error message for invalid email or password
  2. Account Status Validation: Checks ACTIVE, SUSPENDED, BANNED, expired states
  3. Login Metrics: Records success/failure for monitoring
  4. Last Login Tracking: Updates lastLoginAt timestamp
  5. 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 registered
  • 400 Bad Request: Password doesn't meet complexity requirements
  • 429 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 USER server-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:

  1. Atomic Rotation: Old token deleted and new token created in single transaction
  2. Expiration Check: Validates refresh token hasn't expired
  3. Database Validation: Checks token exists in database (prevents replay attacks)
  4. 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 token
  • 401 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:

  1. Find user by email
  2. Compare password with bcrypt
  3. Validate account status (ACTIVE, not expired)
  4. Record login metrics
  5. Update lastLoginAt timestamp
  6. Generate access + refresh token pair
  7. 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:

  1. Check if email already exists
  2. Hash password with bcrypt (12 salt rounds)
  3. Create user with USER role
  4. Generate token pair
  5. 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_SECRET environment 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/me returns 401 (not 404) 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 select or destructuring)
  • Refresh tokens cascade deleted when user deleted
  • Unique constraint on email prevents duplicates
  • Foreign key constraints ensure referential integrity