21 KiB

Authentication Flow

Changemaker Lite V2 uses JWT-based authentication with access and refresh tokens for stateless, scalable authentication.

Overview

Key Features:

  • JWT Tokens - Stateless authentication (no session storage)
  • Dual Token System - Short-lived access tokens (15min) + long-lived refresh tokens (7 days)
  • Refresh Token Rotation - Atomic transaction prevents race conditions
  • Password Policy - Enforced 12+ characters with complexity requirements
  • Rate Limiting - 10 requests/min on auth endpoints
  • User Enumeration Prevention - Consistent 401 responses
  • RBAC - Role-based access control with 5 roles

Authentication Architecture

graph TB
    subgraph "Client Layer"
        Browser[Web Browser]
        Storage[LocalStorage<br/>Zustand Persist]
    end

    subgraph "API Layer"
        AuthRoutes[Auth Routes<br/>/api/auth/*]
        AuthMiddleware[Auth Middleware<br/>JWT Verification]
        RBACMiddleware[RBAC Middleware<br/>Role Check]
    end

    subgraph "Data Layer"
        PG[(PostgreSQL<br/>User + RefreshToken)]
        Redis[(Redis<br/>Rate Limiting)]
    end

    Browser -->|POST /auth/login| AuthRoutes
    AuthRoutes -->|Check rate limit| Redis
    AuthRoutes -->|Verify credentials| PG
    AuthRoutes -->|Generate tokens| AuthRoutes
    AuthRoutes -->|Store refresh token| PG
    AuthRoutes -->|Return tokens| Browser
    Browser -->|Store| Storage

    Browser -->|API requests| AuthMiddleware
    AuthMiddleware -->|Verify JWT| AuthMiddleware
    AuthMiddleware -->|Check role| RBACMiddleware
    RBACMiddleware -->|Authorized| Handler[Route Handler]

    style AuthRoutes fill:#61dafb,stroke:#333,stroke-width:2px
    style AuthMiddleware fill:#ffd700,stroke:#333,stroke-width:2px
    style RBACMiddleware fill:#ff6b6b,stroke:#333,stroke-width:2px

User Roles

Role Hierarchy

enum UserRole {
  SUPER_ADMIN      = 'SUPER_ADMIN',      // Full system access
  INFLUENCE_ADMIN  = 'INFLUENCE_ADMIN',  // Campaign management
  MAP_ADMIN        = 'MAP_ADMIN',        // Location + canvassing management
  USER             = 'USER',             // Standard user (limited access)
  TEMP             = 'TEMP'              // Temporary user (public signups, auto-expires)
}

Role Permissions

Role Campaign CRUD Response Moderation Location Management User Management System Settings
SUPER_ADMIN
INFLUENCE_ADMIN
MAP_ADMIN
USER
TEMP

TEMP User Behavior:

  • Created automatically for public shift signups
  • Auto-expires after configured days (expiresAt, expireDays fields)
  • Limited to volunteer canvassing features
  • Cannot access admin pages

Login Flow

Sequence Diagram

sequenceDiagram
    participant User
    participant React as Admin GUI
    participant Nginx
    participant API as Express API
    participant Redis
    participant PG as PostgreSQL

    User->>React: Enter email + password
    React->>Nginx: POST /api/auth/login
    Nginx->>API: Forward request

    API->>Redis: Rate limit check (10/min)
    alt Rate limit exceeded
        Redis-->>API: Too many requests
        API-->>React: 429 Too Many Requests
        React-->>User: "Try again later"
    else Rate limit OK
        API->>PG: SELECT * FROM User WHERE email = ?
        alt User not found
            PG-->>API: null
            API-->>React: 401 Unauthorized
            React-->>User: "Invalid credentials"
        else User found
            PG-->>API: User record
            API->>API: bcrypt.compare(password, hash)
            alt Password invalid
                API-->>React: 401 Unauthorized
                React-->>User: "Invalid credentials"
            else Password valid
                API->>API: Check user status
                alt Status SUSPENDED
                    API-->>React: 403 Forbidden
                    React-->>User: "Account suspended"
                else Status ACTIVE
                    API->>API: jwt.sign(accessPayload, 15min)
                    API->>API: jwt.sign(refreshPayload, 7d)
                    API->>PG: INSERT RefreshToken
                    API->>PG: UPDATE lastLoginAt
                    API-->>React: { user, accessToken, refreshToken }
                    React->>React: Store in Zustand + localStorage
                    React-->>User: Redirect to dashboard
                end
            end
        end
    end

Implementation

File: api/src/modules/auth/auth.service.ts (lines 22-56)

import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { prisma } from '../../config/database';
import { loginSchema } from './auth.schemas';
import { incrementMetric } from '../../utils/metrics';

export async function login(credentials: { email: string; password: string }) {
  // Validate input
  const { email, password } = loginSchema.parse(credentials);

  // Find user
  const user = await prisma.user.findUnique({
    where: { email },
    select: {
      id: true,
      email: true,
      password: true,
      name: true,
      role: true,
      status: true,
      emailVerified: true,
      expiresAt: true
    }
  });

  // User enumeration prevention: consistent 401 response
  if (!user) {
    throw new Error('Invalid credentials'); // Returns 401
  }

  // Verify password
  const isValid = await bcrypt.compare(password, user.password);
  if (!isValid) {
    throw new Error('Invalid credentials'); // Returns 401
  }

  // Check user status
  if (user.status === 'SUSPENDED') {
    throw new Error('Account suspended'); // Returns 403
  }
  if (user.status === 'INACTIVE') {
    throw new Error('Account inactive'); // Returns 403
  }

  // Check TEMP user expiration
  if (user.expiresAt && new Date() > user.expiresAt) {
    await prisma.user.update({
      where: { id: user.id },
      data: { status: 'EXPIRED' }
    });
    throw new Error('Account expired'); // Returns 403
  }

  // Generate access token (15 minutes)
  const accessToken = jwt.sign(
    { id: user.id, email: user.email, role: user.role },
    process.env.JWT_ACCESS_SECRET!,
    { expiresIn: '15m' as const }
  );

  // Generate refresh token (7 days)
  const refreshToken = jwt.sign(
    { id: user.id, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET!,
    { expiresIn: '7d' as const }
  );

  // Store refresh token in database
  await prisma.refreshToken.create({
    data: {
      token: refreshToken,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
    }
  });

  // Update last login timestamp
  await prisma.user.update({
    where: { id: user.id },
    data: { lastLoginAt: new Date() }
  });

  // Increment metrics
  incrementMetric('cm_login_attempts_total', { status: 'success', role: user.role });

  // Return user (no password) + tokens
  const { password: _, ...userWithoutPassword } = user;
  return {
    user: userWithoutPassword,
    accessToken,
    refreshToken
  };
}

Password Policy

Enforced at Zod schema level:

File: api/src/modules/auth/auth.schemas.ts (lines 9-16)

import { z } from 'zod';

export const passwordSchema = 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');

export const registerSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: passwordSchema,
  name: z.string().min(2, 'Name must be at least 2 characters')
});

export const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(1, 'Password is required')
});

Policy Requirements:

  • Minimum 12 characters
  • At least one uppercase letter (A-Z)
  • At least one lowercase letter (a-z)
  • At least one digit (0-9)

Note: Policy is NOT enforced on login (only on registration/password change) to avoid breaking existing accounts.

Refresh Token Flow

Sequence Diagram

sequenceDiagram
    participant React as Admin GUI
    participant API as Express API
    participant PG as PostgreSQL

    Note over React: Access token expires (15min)
    React->>React: Detect 401 Unauthorized
    React->>API: POST /api/auth/refresh
    Note right of React: Send refresh token

    API->>API: jwt.verify(refreshToken)
    alt Token invalid/expired
        API-->>React: 401 Unauthorized
        React->>React: Clear auth state
        React-->>User: Redirect to login
    else Token valid
        API->>PG: BEGIN TRANSACTION
        API->>PG: SELECT RefreshToken WHERE token = ?
        alt Token not in database
            API->>PG: ROLLBACK
            API-->>React: 401 Unauthorized
        else Token found
            API->>PG: DELETE FROM RefreshToken WHERE token = ?
            API->>API: Generate new access token (15min)
            API->>API: Generate new refresh token (7d)
            API->>PG: INSERT new RefreshToken
            API->>PG: COMMIT TRANSACTION
            API-->>React: { accessToken, refreshToken }
            React->>React: Update stored tokens
            React->>React: Retry original request
        end
    end

Implementation

File: api/src/modules/auth/auth.service.ts (lines 82-130)

export async function refreshTokens(refreshToken: string) {
  // Verify refresh token signature
  let payload: any;
  try {
    payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!);
  } catch (err) {
    throw new Error('Invalid refresh token'); // Returns 401
  }

  // Atomic transaction for token rotation
  const result = await prisma.$transaction(async (tx) => {
    // Check if refresh token exists in database
    const storedToken = await tx.refreshToken.findUnique({
      where: { token: refreshToken },
      include: { user: true }
    });

    if (!storedToken) {
      throw new Error('Refresh token not found'); // Returns 401
    }

    // Check expiration
    if (new Date() > storedToken.expiresAt) {
      // Delete expired token
      await tx.refreshToken.delete({
        where: { token: refreshToken }
      });
      throw new Error('Refresh token expired'); // Returns 401
    }

    // Check user status
    if (storedToken.user.status !== 'ACTIVE') {
      throw new Error('User account not active'); // Returns 403
    }

    // Delete old refresh token (rotation)
    await tx.refreshToken.delete({
      where: { token: refreshToken }
    });

    // Generate new access token
    const newAccessToken = jwt.sign(
      { id: storedToken.user.id, email: storedToken.user.email, role: storedToken.user.role },
      process.env.JWT_ACCESS_SECRET!,
      { expiresIn: '15m' as const }
    );

    // Generate new refresh token
    const newRefreshToken = jwt.sign(
      { id: storedToken.user.id, type: 'refresh' },
      process.env.JWT_REFRESH_SECRET!,
      { expiresIn: '7d' as const }
    );

    // Store new refresh token
    await tx.refreshToken.create({
      data: {
        token: newRefreshToken,
        userId: storedToken.user.id,
        expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
      }
    });

    return {
      accessToken: newAccessToken,
      refreshToken: newRefreshToken
    };
  });

  return result;
}

Critical: Refresh token rotation happens in a single database transaction to prevent race conditions (e.g., multiple refresh attempts).

Frontend Integration

Zustand Auth Store

File: admin/src/stores/auth.store.ts (lines 1-100)

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface User {
  id: string;
  email: string;
  name: string | null;
  role: string;
}

interface AuthState {
  user: User | null;
  accessToken: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;

  login: (user: User, accessToken: string, refreshToken: string) => void;
  logout: () => void;
  updateTokens: (accessToken: string, refreshToken: string) => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      accessToken: null,
      refreshToken: null,
      isAuthenticated: false,

      login: (user, accessToken, refreshToken) => {
        set({
          user,
          accessToken,
          refreshToken,
          isAuthenticated: true
        });
      },

      logout: () => {
        set({
          user: null,
          accessToken: null,
          refreshToken: null,
          isAuthenticated: false
        });
      },

      updateTokens: (accessToken, refreshToken) => {
        set({ accessToken, refreshToken });
      }
    }),
    {
      name: 'auth-storage', // LocalStorage key
      partialize: (state) => ({
        user: state.user,
        accessToken: state.accessToken,
        refreshToken: state.refreshToken,
        isAuthenticated: state.isAuthenticated
      })
    }
  )
);

Axios 401 Interceptor

File: admin/src/lib/api.ts (lines 34-78)

import axios from 'axios';
import { useAuthStore } from '../stores/auth.store';

export const api = axios.create({
  baseURL: '/api',
  headers: {
    'Content-Type': 'application/json'
  }
});

// Request interceptor: Add access token to all requests
api.interceptors.request.use((config) => {
  const { accessToken } = useAuthStore.getState();
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

// Response interceptor: Handle 401 with token refresh
let isRefreshing = false;
let refreshCallbacks: ((token: string) => void)[] = [];

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // If 401 and we haven't tried refreshing yet
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      const { refreshToken, updateTokens, logout } = useAuthStore.getState();

      if (!refreshToken) {
        logout();
        window.location.href = '/login';
        return Promise.reject(error);
      }

      // Deduplicate refresh requests (only one refresh at a time)
      if (isRefreshing) {
        // Wait for ongoing refresh to complete
        return new Promise((resolve) => {
          refreshCallbacks.push((token: string) => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            resolve(api(originalRequest));
          });
        });
      }

      isRefreshing = true;

      try {
        // Refresh tokens
        const { data } = await axios.post('/api/auth/refresh', { refreshToken });

        // Update stored tokens
        updateTokens(data.accessToken, data.refreshToken);

        // Retry original request with new token
        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;

        // Resolve queued requests
        refreshCallbacks.forEach((callback) => callback(data.accessToken));
        refreshCallbacks = [];

        return api(originalRequest);
      } catch (refreshError) {
        // Refresh failed, logout
        logout();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

Key Features:

  • Automatic token refresh on 401
  • Deduplicates concurrent refresh requests (callback queue)
  • Retries original request after refresh
  • Logs out on refresh failure

Middleware

JWT Verification

File: api/src/middleware/auth.ts (lines 1-35)

import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';

export interface AuthUser {
  id: string;
  email: string;
  role: string;
}

declare global {
  namespace Express {
    interface Request {
      user?: AuthUser;
    }
  }
}

export const authenticate = (req: Request, res: Response, next: NextFunction) => {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET!) as AuthUser;
    req.user = payload; // Attach user to request
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
};

Role-Based Access Control (RBAC)

File: api/src/middleware/auth.ts (lines 37-55)

export const requireRole = (...allowedRoles: string[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: allowedRoles,
        current: req.user.role
      });
    }

    next();
  };
};

// Block TEMP users from specific routes
export const requireNonTemp = (req: Request, res: Response, next: NextFunction) => {
  if (req.user?.role === 'TEMP') {
    return res.status(403).json({ error: 'Temporary users cannot access this resource' });
  }
  next();
};

Usage:

import { authenticate, requireRole, requireNonTemp } from './middleware/auth';

// Require authentication
router.get('/profile', authenticate, getProfile);

// Require specific role
router.post('/campaigns', authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'), createCampaign);

// Block TEMP users
router.post('/users', authenticate, requireNonTemp, createUser);

Rate Limiting

File: api/src/middleware/rate-limit.ts (lines 1-45)

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../config/redis';

// Auth endpoints: 10 requests per minute
export const authRateLimit = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:auth:',
    sendCommand: (...args: string[]) => redis.call(...args)
  }),
  windowMs: 60 * 1000, // 1 minute
  max: 10,
  message: 'Too many auth requests, please try again later',
  standardHeaders: true,
  legacyHeaders: false
});

// Apply to auth routes
import authRoutes from './modules/auth/auth.routes';
app.use('/api/auth/login', authRateLimit);
app.use('/api/auth/register', authRateLimit);
app.use('/api/auth/refresh', authRateLimit);

Security Features

1. User Enumeration Prevention

Problem: Attackers can enumerate valid emails by observing different error messages.

Solution: Consistent 401 response for both "user not found" and "invalid password":

if (!user) {
  throw new Error('Invalid credentials'); // Same message
}

if (!isValidPassword) {
  throw new Error('Invalid credentials'); // Same message
}

2. Password Hashing

bcryptjs with automatic salt generation:

import bcrypt from 'bcryptjs';

// Registration
const hashedPassword = await bcrypt.hash(password, 10); // 10 rounds
await prisma.user.create({
  data: { email, password: hashedPassword, name, role: 'USER' }
});

// Login
const isValid = await bcrypt.compare(password, user.password);

Rounds: 10 (balanced between security and performance)

3. Refresh Token Rotation

Prevents replay attacks:

  • Old refresh token deleted immediately after use (atomic transaction)
  • New refresh token issued with each refresh
  • If old token reused → 401 error

4. Token Expiration

Token Type Lifetime Storage Purpose
Access 15 minutes Not stored (JWT only) API authentication
Refresh 7 days Database + localStorage Token renewal

Short access token lifetime limits damage if token is stolen.

5. Redis Authentication

Redis requires password authentication:

# .env
REDIS_PASSWORD=strong_password_here

# Redis connection
REDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379

Troubleshooting

Login Fails with Correct Password

Cause: User status not ACTIVE, or TEMP user expired.

Solution:

-- Check user status
SELECT email, status, expiresAt FROM "User" WHERE email = 'user@example.com';

-- Activate user
UPDATE "User" SET status = 'ACTIVE' WHERE email = 'user@example.com';

Token Refresh Fails

Cause: Refresh token not in database (deleted or expired).

Solution:

-- Check if refresh token exists
SELECT * FROM "RefreshToken" WHERE token = 'token_here';

-- Delete all expired tokens
DELETE FROM "RefreshToken" WHERE "expiresAt" < NOW();

401 on All Requests

Cause: Access token missing, invalid, or expired.

Debug:

# Decode JWT (without verifying signature)
echo "eyJhbG..." | cut -d'.' -f2 | base64 -d | jq

# Check expiration
# Look for "exp" field (Unix timestamp)

Circular Dependency (auth.store ↔ api.ts)

Problem: auth.store imports api.ts, api.ts imports auth.store (circular).

Solution: Callback registration pattern (already implemented in api.ts lines 34-78).

Further Reading