← 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)
}