# 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 ```mermaid graph TB subgraph "Client Layer" Browser[Web Browser] Storage[LocalStorage
Zustand Persist] end subgraph "API Layer" AuthRoutes[Auth Routes
/api/auth/*] AuthMiddleware[Auth Middleware
JWT Verification] RBACMiddleware[RBAC Middleware
Role Check] end subgraph "Data Layer" PG[(PostgreSQL
User + RefreshToken)] Redis[(Redis
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 ```typescript 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 ```mermaid 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) ```typescript 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) ```typescript 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 ```mermaid 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) ```typescript 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) ```typescript 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()( 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) ```typescript 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) ```typescript 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) ```typescript 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:** ```typescript 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) ```typescript 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": ```typescript 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:** ```typescript 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: ```bash # .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:** ```sql -- 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:** ```sql -- 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:** ```bash # 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 - [RBAC Patterns](../backend/middleware/rbac.md) - Advanced role checks - [Security Model](security.md) - Comprehensive security audit - [Database Schema](database.md) - User and RefreshToken models - [Frontend State Management](frontend.md) - Zustand auth store - [API Reference: Auth](../api-reference/auth.md) - Complete endpoint docs