Email Verification System/Phase 2 of 530-45 minutes

Token Generation Service

Phase 2: Token Generation Service

Overview

Build the core service for generating, storing, and validating verification tokens. This is the security-critical component.

Requirements

Functional:

  • β€’Generate cryptographically secure tokens
  • β€’Configure token expiry duration
  • β€’Check token validity and expiration
  • β€’Support token invalidation

Non-functional:

  • β€’Use crypto-secure random bytes
  • β€’Constant-time comparison for validation
  • β€’No token reuse

Implementation

Token Utility Functions

// src/lib/verification/token.ts
import crypto from 'crypto'

/**
 * Generate a cryptographically secure verification token
 * Uses 32 bytes = 64 hex characters
 */
export function generateToken(): string {
  return crypto.randomBytes(32).toString('hex')
}

/**
 * Calculate expiry timestamp
 * @param hours - Hours until expiry (default: 24)
 */
export function getTokenExpiry(hours: number = 24): Date {
  return new Date(Date.now() + hours * 60 * 60 * 1000)
}

/**
 * Check if a token has expired
 */
export function isTokenExpired(expiresAt: Date): boolean {
  return new Date() > new Date(expiresAt)
}

/**
 * Constant-time string comparison to prevent timing attacks
 */
export function secureCompare(a: string, b: string): boolean {
  if (a.length !== b.length) return false
  return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
}

Token Service Class

// src/lib/verification/service.ts
import { db } from '@/lib/db'
import { generateToken, getTokenExpiry, isTokenExpired } from './token'

export type TokenType = 'email_verification' | 'password_reset' | 'magic_link'

interface CreateTokenOptions {
  userId: string
  type?: TokenType
  expiryHours?: number
}

interface TokenValidationResult {
  valid: boolean
  userId?: string
  error?: 'invalid' | 'expired' | 'used'
}

export class VerificationService {
  /**
   * Create a new verification token for a user
   * Deletes any existing tokens of same type first
   */
  async createToken(options: CreateTokenOptions): Promise<string> {
    const { userId, type = 'email_verification', expiryHours = 24 } = options

    // Invalidate existing tokens of same type
    await db.verificationToken.deleteMany({
      where: { userId, type }
    })

    const token = generateToken()
    
    await db.verificationToken.create({
      data: {
        userId,
        token,
        type,
        expiresAt: getTokenExpiry(expiryHours)
      }
    })

    return token
  }

  /**
   * Validate a token and return associated user info
   * Does NOT consume the token - call consumeToken after successful action
   */
  async validateToken(token: string, type: TokenType = 'email_verification'): Promise<TokenValidationResult> {
    const record = await db.verificationToken.findUnique({
      where: { token },
      include: { user: true }
    })

    if (!record || record.type !== type) {
      return { valid: false, error: 'invalid' }
    }

    if (isTokenExpired(record.expiresAt)) {
      // Clean up expired token
      await db.verificationToken.delete({ where: { id: record.id } })
      return { valid: false, error: 'expired' }
    }

    return { valid: true, userId: record.userId }
  }

  /**
   * Consume (delete) a token after successful use
   */
  async consumeToken(token: string): Promise<void> {
    await db.verificationToken.delete({ where: { token } })
  }

  /**
   * Verify email and consume token in one transaction
   */
  async verifyEmail(token: string): Promise<{ success: boolean; error?: string }> {
    const validation = await this.validateToken(token, 'email_verification')
    
    if (!validation.valid) {
      return { 
        success: false, 
        error: validation.error === 'expired' 
          ? 'Verification link has expired' 
          : 'Invalid verification link'
      }
    }

    // Mark user as verified and consume token
    await db.$transaction([
      db.user.update({
        where: { id: validation.userId },
        data: { emailVerifiedAt: new Date() }
      }),
      db.verificationToken.delete({ where: { token } })
    ])

    return { success: true }
  }

  /**
   * Check if user already verified
   */
  async isUserVerified(userId: string): Promise<boolean> {
    const user = await db.user.findUnique({
      where: { id: userId },
      select: { emailVerifiedAt: true }
    })
    return user?.emailVerifiedAt !== null
  }
}

// Export singleton instance
export const verificationService = new VerificationService()

Configuration Constants

// src/lib/verification/config.ts
export const VERIFICATION_CONFIG = {
  // Token settings
  TOKEN_BYTES: 32,
  DEFAULT_EXPIRY_HOURS: 24,
  
  // Rate limiting
  MAX_RESEND_ATTEMPTS: 3,
  RESEND_COOLDOWN_MINUTES: 5,
  
  // URLs
  VERIFY_PATH: '/verify-email',
  
  getVerifyUrl(token: string): string {
    const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
    return `${baseUrl}${this.VERIFY_PATH}?token=${token}`
  }
}

Testing

// src/lib/verification/__tests__/token.test.ts
import { generateToken, isTokenExpired, getTokenExpiry } from '../token'

describe('Token Generation', () => {
  it('generates 64-character hex tokens', () => {
    const token = generateToken()
    expect(token).toHaveLength(64)
    expect(token).toMatch(/^[a-f0-9]+$/)
  })

  it('generates unique tokens', () => {
    const tokens = new Set(Array.from({ length: 100 }, generateToken))
    expect(tokens.size).toBe(100)
  })
})

describe('Token Expiry', () => {
  it('creates future expiry date', () => {
    const expiry = getTokenExpiry(24)
    expect(expiry.getTime()).toBeGreaterThan(Date.now())
  })

  it('detects expired tokens', () => {
    const pastDate = new Date(Date.now() - 1000)
    expect(isTokenExpired(pastDate)).toBe(true)
  })

  it('detects valid tokens', () => {
    const futureDate = new Date(Date.now() + 1000)
    expect(isTokenExpired(futureDate)).toBe(false)
  })
})

Success Criteria

  • β€’ Token generation produces unique 64-char hex strings
  • β€’ Expiry calculation correct for various durations
  • β€’ Old tokens deleted when creating new one
  • β€’ Validation returns correct error types
  • β€’ Token consumed after successful verification

Related Files

  • β€’Create: src/lib/verification/token.ts
  • β€’Create: src/lib/verification/service.ts
  • β€’Create: src/lib/verification/config.ts
  • β€’Create: src/lib/verification/index.ts (exports)