← back to Handbag Auth Nextjs
src/lib/auth.ts
226 lines
/**
* Authentication and Authorization Middleware
* Implements JWT-based auth with secure token handling
* OWASP: Authentication (https://owasp.org/www-project-web-security-testing-guide/stable/4-Web_Application_Security_Testing/04-Authentication_Testing/README)
*/
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
// JWT secret must come from the environment — no fallback (audit 2026-05-04)
const JWT_SECRET: string = process.env.JWT_SECRET ?? (() => {
throw new Error('JWT_SECRET env var required (no fallback for production safety) — audit 2026-05-04');
})()
const JWT_EXPIRES_IN = '24h'
const SALT_ROUNDS = 12
// User roles for authorization
export enum UserRole {
ADMIN = 'admin',
USER = 'user',
READONLY = 'readonly',
}
// JWT payload interface
export interface JWTPayload {
userId: string
email: string
role: UserRole
iat?: number
exp?: number
}
// Session validation schema
const sessionSchema = z.object({
userId: z.string(),
email: z.string().email(),
role: z.enum([UserRole.ADMIN, UserRole.USER, UserRole.READONLY]),
})
/**
* Hash a password using bcrypt
*/
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
/**
* Verify a password against a hash
*/
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
/**
* Generate a JWT token
*/
export function generateToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
issuer: 'handbag-marketplace',
audience: 'handbag-api',
})
}
/**
* Verify and decode a JWT token
*/
export function verifyToken(token: string): JWTPayload | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, {
issuer: 'handbag-marketplace',
audience: 'handbag-api',
}) as JWTPayload
// Additional validation using Zod
const result = sessionSchema.safeParse(decoded)
if (!result.success) {
console.error('Invalid token payload:', result.error)
return null
}
return decoded
} catch (error) {
console.error('Token verification failed:', error)
return null
}
}
/**
* Extract token from request headers
*/
export function extractToken(request: NextRequest): string | null {
const authHeader = request.headers.get('authorization')
if (!authHeader) return null
const [type, token] = authHeader.split(' ')
if (type !== 'Bearer' || !token) return null
return token
}
/**
* Middleware to verify authentication
*/
export async function requireAuth(
request: NextRequest,
requiredRole?: UserRole
): Promise<JWTPayload | NextResponse> {
const token = extractToken(request)
if (!token) {
return NextResponse.json(
{ success: false, error: 'Authentication required' },
{ status: 401 }
)
}
const payload = verifyToken(token)
if (!payload) {
return NextResponse.json(
{ success: false, error: 'Invalid or expired token' },
{ status: 401 }
)
}
// Check role-based authorization
if (requiredRole) {
if (requiredRole === UserRole.ADMIN && payload.role !== UserRole.ADMIN) {
return NextResponse.json(
{ success: false, error: 'Admin access required' },
{ status: 403 }
)
}
if (requiredRole === UserRole.USER && payload.role === UserRole.READONLY) {
return NextResponse.json(
{ success: false, error: 'Write access required' },
{ status: 403 }
)
}
}
return payload
}
/**
* Generate CSRF token for form submissions
*/
export function generateCSRFToken(): string {
const token = jwt.sign(
{ csrf: true, timestamp: Date.now() },
JWT_SECRET,
{ expiresIn: '1h' }
)
return token
}
/**
* Verify CSRF token
*/
export function verifyCSRFToken(token: string): boolean {
try {
jwt.verify(token, JWT_SECRET)
return true
} catch {
return false
}
}
/**
* Create secure session cookie options
*/
export function getSecureCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict' as const,
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
}
}
/**
* Rate limit login attempts to prevent brute force
*/
const loginAttempts = new Map<string, { count: number; resetTime: number }>()
export function checkLoginRateLimit(identifier: string): boolean {
const now = Date.now()
const attempt = loginAttempts.get(identifier)
if (!attempt || now > attempt.resetTime) {
loginAttempts.set(identifier, {
count: 1,
resetTime: now + 15 * 60 * 1000, // 15 minutes
})
return true
}
if (attempt.count >= 5) {
return false // Too many attempts
}
attempt.count++
return true
}
/**
* Clear expired login attempts (cleanup)
*/
export function clearExpiredLoginAttempts(): void {
const now = Date.now()
for (const [key, attempt] of loginAttempts.entries()) {
if (now > attempt.resetTime) {
loginAttempts.delete(key)
}
}
}
// Run cleanup every 5 minutes
if (typeof window === 'undefined') {
setInterval(clearExpiredLoginAttempts, 5 * 60 * 1000)
}