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)