Includes all Phase 1 features: - Search-first navigation with auto-complete - Responsive reading interface (desktop/tablet/mobile) - 4 customization presets + full fine-tuning controls - Layered details panel with notes, bookmarks, highlights - Smart offline caching with IndexedDB and auto-sync - Full accessibility (WCAG 2.1 AA) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
24 KiB
24 KiB
Payload CMS Authentication Migration Guide
Overview
This guide provides detailed steps for migrating from the current JWT-based authentication system to Payload CMS's built-in authentication system while maintaining backward compatibility and ensuring zero downtime.
Current Authentication System Analysis
Existing Implementation
- Technology: Custom JWT implementation with bcryptjs
- Token Expiry: 7 days
- Storage: PostgreSQL (User, AdminUser tables)
- Roles: USER, ADMIN, SUPER_ADMIN
- Session Management: Stateless JWT tokens
Current Auth Flow
graph LR
A[User Login] --> B[Validate Credentials]
B --> C[Generate JWT]
C --> D[Return Token]
D --> E[Store in LocalStorage]
E --> F[Include in Headers]
F --> G[Verify on Each Request]
Payload Authentication System
Key Features
- Cookie-based sessions with HTTP-only cookies
- CSRF protection built-in
- Refresh tokens for extended sessions
- Password reset flow with email verification
- Two-factor authentication support (optional)
- OAuth providers integration capability
Payload Auth Flow
graph LR
A[User Login] --> B[Validate Credentials]
B --> C[Create Session]
C --> D[Set HTTP-only Cookie]
D --> E[Return User Data]
E --> F[Auto-include Cookie]
F --> G[Session Validation]
Migration Strategy
Phase 1: Dual Authentication Support
Step 1.1: Configure Payload Auth
// config/auth.config.ts
export const authConfig = {
// Enable both JWT and session-based auth
cookies: {
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
domain: process.env.COOKIE_DOMAIN,
},
tokenExpiration: 604800, // 7 days (matching current)
maxLoginAttempts: 5,
lockTime: 600000, // 10 minutes
// Custom JWT for backward compatibility
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: '7d',
},
// Session configuration
session: {
resave: false,
saveUninitialized: false,
secret: process.env.SESSION_SECRET,
cookie: {
maxAge: 604800000, // 7 days in milliseconds
},
},
};
Step 1.2: Create Compatibility Layer
// lib/auth/compatibility.ts
import jwt from 'jsonwebtoken';
import { PayloadRequest } from 'payload/types';
export class AuthCompatibilityLayer {
/**
* Validates both old JWT tokens and new Payload sessions
*/
static async validateRequest(req: PayloadRequest) {
// Check for Payload session first
if (req.user) {
return { valid: true, user: req.user, method: 'payload' };
}
// Check for legacy JWT token
const token = this.extractJWT(req);
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
const user = await this.getUserFromToken(decoded);
return { valid: true, user, method: 'jwt' };
} catch (error) {
return { valid: false, error: 'Invalid token' };
}
}
return { valid: false, error: 'No authentication provided' };
}
private static extractJWT(req: PayloadRequest): string | null {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
return authHeader.substring(7);
}
return null;
}
private static async getUserFromToken(decoded: any) {
// Fetch user from Payload collections
const user = await payload.findByID({
collection: 'users',
id: decoded.userId,
});
return user;
}
/**
* Generates both JWT (for legacy) and creates Payload session
*/
static async createDualAuth(user: any, req: PayloadRequest) {
// Create Payload session
const payloadToken = await payload.login({
collection: 'users',
data: {
email: user.email,
password: user.password,
},
req,
});
// Generate legacy JWT
const jwtToken = jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role,
},
process.env.JWT_SECRET!,
{ expiresIn: '7d' }
);
return {
payloadToken,
jwtToken, // For backward compatibility
user,
};
}
}
Phase 2: User Migration
Step 2.1: User Data Migration Script
// scripts/migrate-users.ts
import { PrismaClient } from '@prisma/client';
import payload from 'payload';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
interface MigrationResult {
success: number;
failed: number;
errors: Array<{ email: string; error: string }>;
}
export async function migrateUsers(): Promise<MigrationResult> {
const result: MigrationResult = {
success: 0,
failed: 0,
errors: [],
};
// Fetch all users from Prisma
const users = await prisma.user.findMany({
include: {
subscription: true,
userSettings: true,
bookmarks: true,
highlights: true,
},
});
console.log(`Starting migration of ${users.length} users...`);
for (const user of users) {
try {
// Check if user already exists in Payload
const existing = await payload.find({
collection: 'users',
where: {
email: { equals: user.email },
},
});
if (existing.docs.length > 0) {
console.log(`User ${user.email} already migrated, skipping...`);
continue;
}
// Create user in Payload
const payloadUser = await payload.create({
collection: 'users',
data: {
email: user.email,
name: user.name,
role: user.role,
// Password handling - already hashed
password: user.password,
_verified: true, // Mark as verified
// Custom fields
stripeCustomerId: user.stripeCustomerId,
favoriteVersion: user.favoriteVersion || 'VDC',
// Settings
profileSettings: {
fontSize: user.userSettings?.fontSize || 16,
theme: user.userSettings?.theme || 'light',
showVerseNumbers: user.userSettings?.showVerseNumbers ?? true,
enableNotifications: user.userSettings?.enableNotifications ?? true,
},
// Timestamps
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastLogin: user.lastLogin,
},
});
// Migrate related data
if (user.subscription) {
await migrateUserSubscription(payloadUser.id, user.subscription);
}
if (user.bookmarks.length > 0) {
await migrateUserBookmarks(payloadUser.id, user.bookmarks);
}
if (user.highlights.length > 0) {
await migrateUserHighlights(payloadUser.id, user.highlights);
}
result.success++;
console.log(`✓ Migrated user: ${user.email}`);
} catch (error) {
result.failed++;
result.errors.push({
email: user.email,
error: error.message,
});
console.error(`✗ Failed to migrate user ${user.email}:`, error);
}
}
return result;
}
async function migrateUserSubscription(userId: string, subscription: any) {
await payload.create({
collection: 'subscriptions',
data: {
user: userId,
stripeSubscriptionId: subscription.stripeSubscriptionId,
planName: subscription.planName,
status: subscription.status,
currentPeriodStart: subscription.currentPeriodStart,
currentPeriodEnd: subscription.currentPeriodEnd,
conversationCount: subscription.conversationCount,
},
});
}
async function migrateUserBookmarks(userId: string, bookmarks: any[]) {
for (const bookmark of bookmarks) {
await payload.create({
collection: 'bookmarks',
data: {
user: userId,
book: bookmark.book,
chapter: bookmark.chapter,
verse: bookmark.verse,
note: bookmark.note,
createdAt: bookmark.createdAt,
},
});
}
}
async function migrateUserHighlights(userId: string, highlights: any[]) {
for (const highlight of highlights) {
await payload.create({
collection: 'highlights',
data: {
user: userId,
verseId: highlight.verseId,
color: highlight.color,
note: highlight.note,
createdAt: highlight.createdAt,
},
});
}
}
Step 2.2: Password Migration Strategy
Since passwords are already hashed with bcrypt, we have three options:
Option 1: Direct Hash Migration (Recommended)
// hooks/auth.hooks.ts
export const passwordValidationHook = {
beforeOperation: async ({ args, operation }) => {
if (operation === 'login') {
const { email, password } = args.data;
// Find user
const user = await payload.find({
collection: 'users',
where: { email: { equals: email } },
});
if (user.docs.length === 0) {
throw new Error('Invalid credentials');
}
const userDoc = user.docs[0];
// Check if password needs rehashing (migrated user)
if (userDoc.passwordMigrated) {
// Use bcrypt directly for migrated passwords
const valid = await bcrypt.compare(password, userDoc.password);
if (!valid) {
throw new Error('Invalid credentials');
}
// Rehash with Payload's method on successful login
await payload.update({
collection: 'users',
id: userDoc.id,
data: {
password, // Will be hashed by Payload
passwordMigrated: false,
},
});
}
}
},
};
Option 2: Password Reset Campaign
// scripts/password-reset-campaign.ts
export async function sendPasswordResetToMigratedUsers() {
const migratedUsers = await payload.find({
collection: 'users',
where: {
passwordMigrated: { equals: true },
},
});
for (const user of migratedUsers.docs) {
const token = await payload.forgotPassword({
collection: 'users',
data: { email: user.email },
disableEmail: false,
});
// Send custom email explaining migration
await sendMigrationEmail({
to: user.email,
token,
userName: user.name,
});
}
}
Phase 3: API Endpoint Migration
Step 3.1: Update Frontend API Calls
// lib/api/auth.ts (Frontend)
export class AuthAPI {
private static baseURL = process.env.NEXT_PUBLIC_API_URL;
static async login(email: string, password: string) {
try {
// Try new Payload endpoint first
const response = await fetch(`${this.baseURL}/api/users/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Important for cookies
body: JSON.stringify({ email, password }),
});
if (response.ok) {
const data = await response.json();
// Store JWT for backward compatibility if provided
if (data.token) {
localStorage.setItem('token', data.token);
}
return { success: true, user: data.user };
}
} catch (error) {
console.error('Payload login failed, trying legacy...', error);
}
// Fallback to legacy endpoint
return this.legacyLogin(email, password);
}
private static async legacyLogin(email: string, password: string) {
const response = await fetch(`${this.baseURL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (data.token) {
localStorage.setItem('token', data.token);
}
return data;
}
static async logout() {
// Clear both Payload session and JWT
await fetch(`${this.baseURL}/api/users/logout`, {
method: 'POST',
credentials: 'include',
});
localStorage.removeItem('token');
}
static async getMe() {
// Try Payload endpoint with cookie
try {
const response = await fetch(`${this.baseURL}/api/users/me`, {
credentials: 'include',
});
if (response.ok) {
return response.json();
}
} catch (error) {
// Fallback to JWT
const token = localStorage.getItem('token');
if (token) {
const response = await fetch(`${this.baseURL}/api/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
return response.json();
}
}
throw new Error('Not authenticated');
}
}
Step 3.2: Update API Middleware
// middleware/auth.middleware.ts
import { PayloadRequest } from 'payload/types';
import { AuthCompatibilityLayer } from '../lib/auth/compatibility';
export async function authMiddleware(req: PayloadRequest, res: any, next: any) {
const auth = await AuthCompatibilityLayer.validateRequest(req);
if (!auth.valid) {
return res.status(401).json({ error: auth.error });
}
// Attach user to request for both auth methods
req.user = auth.user;
req.authMethod = auth.method; // Track which auth method was used
// Log for monitoring during migration
console.log(`Auth method: ${auth.method} for user: ${auth.user.email}`);
next();
}
Phase 4: Testing & Validation
Step 4.1: Authentication Test Suite
// tests/auth/migration.test.ts
import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
import payload from 'payload';
import { testUser } from '../fixtures/users';
describe('Authentication Migration Tests', () => {
beforeAll(async () => {
await payload.init({
local: true,
secret: 'test-secret',
});
});
describe('Dual Authentication', () => {
it('should accept legacy JWT tokens', async () => {
const token = generateLegacyJWT(testUser);
const response = await fetch('/api/protected', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
expect(response.status).toBe(200);
});
it('should accept Payload session cookies', async () => {
const loginResponse = await fetch('/api/users/login', {
method: 'POST',
body: JSON.stringify({
email: testUser.email,
password: testUser.password,
}),
credentials: 'include',
});
const cookie = loginResponse.headers.get('set-cookie');
const response = await fetch('/api/protected', {
headers: {
'Cookie': cookie,
},
});
expect(response.status).toBe(200);
});
it('should migrate password on first login', async () => {
const migratedUser = await createMigratedUser();
const response = await fetch('/api/users/login', {
method: 'POST',
body: JSON.stringify({
email: migratedUser.email,
password: 'original-password',
}),
});
expect(response.status).toBe(200);
// Check that password was rehashed
const user = await payload.findByID({
collection: 'users',
id: migratedUser.id,
});
expect(user.passwordMigrated).toBe(false);
});
});
describe('Session Management', () => {
it('should maintain session across requests', async () => {
const session = await createAuthSession(testUser);
// Make multiple requests with same session
for (let i = 0; i < 5; i++) {
const response = await fetch('/api/protected', {
headers: {
'Cookie': session.cookie,
},
});
expect(response.status).toBe(200);
}
});
it('should refresh token before expiry', async () => {
const session = await createAuthSession(testUser);
// Fast-forward time to near expiry
jest.advanceTimersByTime(6 * 24 * 60 * 60 * 1000); // 6 days
const response = await fetch('/api/users/refresh', {
headers: {
'Cookie': session.cookie,
},
});
expect(response.status).toBe(200);
const newCookie = response.headers.get('set-cookie');
expect(newCookie).toBeTruthy();
});
});
describe('Role-Based Access', () => {
it('should enforce admin access', async () => {
const regularUser = await createUser({ role: 'USER' });
const adminUser = await createUser({ role: 'ADMIN' });
const regularSession = await createAuthSession(regularUser);
const adminSession = await createAuthSession(adminUser);
// Regular user should be denied
const regularResponse = await fetch('/api/admin/users', {
headers: { 'Cookie': regularSession.cookie },
});
expect(regularResponse.status).toBe(403);
// Admin should be allowed
const adminResponse = await fetch('/api/admin/users', {
headers: { 'Cookie': adminSession.cookie },
});
expect(adminResponse.status).toBe(200);
});
});
});
Step 4.2: Migration Validation Script
// scripts/validate-migration.ts
export async function validateMigration() {
const report = {
users: { total: 0, migrated: 0, failed: [] },
auth: { jwt: 0, payload: 0, dual: 0 },
subscriptions: { total: 0, active: 0, cancelled: 0 },
errors: [],
};
// Check user migration
const prismaUsers = await prisma.user.count();
const payloadUsers = await payload.count({ collection: 'users' });
report.users.total = prismaUsers;
report.users.migrated = payloadUsers.totalDocs;
// Test authentication methods
const testResults = await testAuthenticationMethods();
report.auth = testResults;
// Validate subscriptions
const subscriptions = await validateSubscriptions();
report.subscriptions = subscriptions;
// Generate report
console.log('Migration Validation Report:');
console.log('============================');
console.log(`Users: ${report.users.migrated}/${report.users.total} migrated`);
console.log(`Auth Methods: JWT: ${report.auth.jwt}, Payload: ${report.auth.payload}`);
console.log(`Subscriptions: ${report.subscriptions.active} active`);
if (report.errors.length > 0) {
console.log('\nErrors found:');
report.errors.forEach(error => console.error(error));
}
return report;
}
Phase 5: Gradual Rollout
Step 5.1: Feature Flags
// lib/features/flags.ts
export const AuthFeatureFlags = {
USE_PAYLOAD_AUTH: process.env.NEXT_PUBLIC_USE_PAYLOAD_AUTH === 'true',
DUAL_AUTH_MODE: process.env.NEXT_PUBLIC_DUAL_AUTH === 'true',
FORCE_PASSWORD_RESET: process.env.FORCE_PASSWORD_RESET === 'true',
};
// Usage in components
export function LoginForm() {
const handleSubmit = async (data: LoginData) => {
if (AuthFeatureFlags.USE_PAYLOAD_AUTH) {
return payloadLogin(data);
} else if (AuthFeatureFlags.DUAL_AUTH_MODE) {
return dualLogin(data);
} else {
return legacyLogin(data);
}
};
}
Step 5.2: A/B Testing
// lib/ab-testing/auth.ts
export function getAuthStrategy(userId?: string): 'legacy' | 'payload' | 'dual' {
// Percentage-based rollout
const rolloutPercentage = parseInt(process.env.PAYLOAD_AUTH_ROLLOUT || '0');
if (!userId) {
// New users always get Payload auth
return 'payload';
}
// Consistent assignment based on user ID
const hash = hashUserId(userId);
const bucket = hash % 100;
if (bucket < rolloutPercentage) {
return 'payload';
} else if (process.env.DUAL_AUTH === 'true') {
return 'dual';
} else {
return 'legacy';
}
}
Phase 6: Monitoring & Observability
Step 6.1: Authentication Metrics
// lib/monitoring/auth-metrics.ts
import { metrics } from '@opentelemetry/api-metrics';
export class AuthMetrics {
private meter = metrics.getMeter('auth-migration');
private loginCounter = this.meter.createCounter('auth_login_total');
private methodHistogram = this.meter.createHistogram('auth_method_duration');
private failureCounter = this.meter.createCounter('auth_failure_total');
trackLogin(method: 'jwt' | 'payload' | 'dual', success: boolean, duration: number) {
this.loginCounter.add(1, {
method,
success: success.toString(),
});
this.methodHistogram.record(duration, {
method,
});
if (!success) {
this.failureCounter.add(1, { method });
}
}
async generateReport() {
return {
totalLogins: await this.getTotalLogins(),
methodDistribution: await this.getMethodDistribution(),
failureRate: await this.getFailureRate(),
avgDuration: await this.getAverageDuration(),
};
}
}
Step 6.2: Monitoring Dashboard
// components/admin/AuthMigrationDashboard.tsx
export function AuthMigrationDashboard() {
const [metrics, setMetrics] = useState<AuthMetrics>();
useEffect(() => {
const fetchMetrics = async () => {
const data = await fetch('/api/admin/auth-metrics').then(r => r.json());
setMetrics(data);
};
fetchMetrics();
const interval = setInterval(fetchMetrics, 30000); // Update every 30s
return () => clearInterval(interval);
}, []);
return (
<div className="dashboard">
<h2>Authentication Migration Status</h2>
<div className="metrics-grid">
<MetricCard
title="Auth Method Distribution"
value={
<PieChart data={[
{ name: 'JWT', value: metrics?.jwt || 0 },
{ name: 'Payload', value: metrics?.payload || 0 },
{ name: 'Dual', value: metrics?.dual || 0 },
]} />
}
/>
<MetricCard
title="Migration Progress"
value={`${metrics?.migratedUsers || 0} / ${metrics?.totalUsers || 0}`}
subtitle={`${Math.round((metrics?.migratedUsers / metrics?.totalUsers) * 100)}% complete`}
/>
<MetricCard
title="Auth Success Rate"
value={`${metrics?.successRate || 0}%`}
trend={metrics?.successTrend}
/>
<MetricCard
title="Active Sessions"
value={metrics?.activeSessions || 0}
subtitle={`JWT: ${metrics?.jwtSessions}, Payload: ${metrics?.payloadSessions}`}
/>
</div>
<div className="recent-issues">
<h3>Recent Authentication Issues</h3>
<IssuesList issues={metrics?.recentIssues || []} />
</div>
</div>
);
}
Rollback Procedures
Emergency Rollback Script
// scripts/auth-rollback.ts
export async function rollbackAuth() {
console.log('Starting authentication rollback...');
// 1. Disable Payload auth endpoints
await updateEnvironmentVariable('USE_PAYLOAD_AUTH', 'false');
// 2. Re-enable legacy endpoints
await updateEnvironmentVariable('USE_LEGACY_AUTH', 'true');
// 3. Clear Payload sessions
await payload.delete({
collection: 'sessions',
where: {},
});
// 4. Notify users
await sendSystemNotification({
message: 'Authentication system maintenance in progress',
type: 'warning',
});
// 5. Monitor legacy auth performance
startLegacyAuthMonitoring();
console.log('Rollback complete. Legacy auth restored.');
}
Best Practices & Recommendations
Security Considerations
- Never log passwords in any form
- Use HTTPS only for production
- Implement rate limiting on auth endpoints
- Monitor failed login attempts
- Regular security audits of auth flows
Performance Optimization
- Cache user sessions in Redis
- Implement session pooling
- Use database indexes on email fields
- Lazy-load user relationships
- CDN for static auth assets
User Experience
- Transparent migration - users shouldn't notice
- Clear error messages for auth failures
- Password strength indicators
- Remember me functionality
- Social login options (future enhancement)
Conclusion
The migration to Payload CMS authentication provides:
- Enhanced security with HTTP-only cookies and CSRF protection
- Better session management with automatic refresh
- Simplified codebase with less custom auth code
- Future-proof architecture for OAuth and 2FA
The dual-authentication approach ensures zero downtime and allows for gradual migration with full rollback capability.
Document Version: 1.0 Last Updated: November 2024 Author: Biblical Guide Development Team