import crypto from 'crypto' import { isAfter, addSeconds } from 'date-fns' import { z } from 'zod' import { KenmonIdentifier, KenmonReturnType, KenmonMailer, KenmonError, KenmonInvalidPayloadError, } from 'kenmon' import { generateSignature } from './signature' // OTP-specific error with reason discriminator export type KenmonEmailOTPErrorReason = | 'not-found' | 'expired' | 'invalid-code' | 'already-used' | 'email-mismatch' export interface KenmonEmailOTP { id: string email: string code: string signature: string expiresAt: Date used: boolean } export class KenmonEmailOTPError extends KenmonError { readonly reason: KenmonEmailOTPErrorReason constructor(reason: KenmonEmailOTPErrorReason) { const messages: Record = { 'not-found': 'OTP not found', expired: 'OTP has expired', 'invalid-code': 'Invalid OTP code', 'already-used': 'OTP has already been used', 'email-mismatch': 'Email does not match OTP', } super(messages[reason]) this.name = 'KenmonOTPError' this.reason = reason Object.setPrototypeOf(this, KenmonEmailOTPError.prototype) } } // Zod schemas for payload validation const emailOTPPrepareDataSchema = z.object({ email: z.email('Invalid email address'), }) const emailOTPAuthenticateDataSchema = z.object({ email: z.email('Invalid email address'), otpId: z.string().min(1, 'OTP ID is required'), code: z.string().min(1, 'OTP code is required'), }) // OTP Storage interface export interface KenmonEmailOTPStorage { createOTP( email: string, code: string, expiresAt: Date, signature: string, ): Promise getOTPById(id: string): Promise markOTPAsUsed(id: string): Promise } // EmailOTP Provider configuration export interface KenmonEmailOTPProviderConfig { mailer: KenmonMailer otpStorage: KenmonEmailOTPStorage otpTtl?: number // seconds, default 300 (5 minutes) otpLength?: number // default 6 emailFrom: string emailSubject?: (code: string, signature: string, otpTtl: number) => string emailTextContent?: (code: string, signature: string, otpTtl: number) => string emailHtmlContent?: (code: string, signature: string, otpTtl: number) => string } export class KenmonEmailOTPAuthenticator { readonly type = 'email-otp' private mailer: KenmonMailer private otpStorage: KenmonEmailOTPStorage private otpTtl: number private otpLength: number private emailFrom: string private emailSubject: ( code: string, signature: string, otpTtl: number, ) => string private emailTextContent: ( code: string, signature: string, otpTtl: number, ) => string private emailHtmlContent: ( code: string, signature: string, otpTtl: number, ) => string constructor(config: KenmonEmailOTPProviderConfig) { this.mailer = config.mailer this.otpStorage = config.otpStorage this.otpTtl = config.otpTtl ?? 300 // 5 minutes default this.otpLength = config.otpLength ?? 6 this.emailFrom = config.emailFrom // Set default email subject this.emailSubject = config.emailSubject ?? ((code: string, signature: string, otpTtl: number) => { return `Verify your email - ${signature}` }) // Set default text content this.emailTextContent = config.emailTextContent ?? ((code: string, signature: string, otpTtl: number) => { return `Your verification code is: ${code} Request Signature: ${signature} ⚠️ Verify this signature matches the one shown on the website before entering your code. This code will expire in ${Math.floor(otpTtl / 60)} minutes.` }) // Set default HTML content this.emailHtmlContent = config.emailHtmlContent ?? ((code: string, signature: string, otpTtl: number) => { return `

Your verification code is:

${code}

Request Signature: ${signature}

⚠️ Verify this signature matches the one shown on the website before entering your code.

This code will expire in ${Math.floor(otpTtl / 60)} minutes.

`.trim() }) } async sendOTP( email: string, ): Promise> { // Validate email const result = emailOTPPrepareDataSchema.safeParse({ email }) if (!result.success) { return { success: false, error: new KenmonInvalidPayloadError(result.error.issues[0].message), } } try { // Generate OTP code const code = this.generateOTPCode() // Generate signature const signature = generateSignature() // Calculate expiry const expiresAt = addSeconds(new Date(), this.otpTtl) // Store OTP const otp = await this.otpStorage.createOTP( email, code, expiresAt, signature, ) // Generate email content const subject = this.emailSubject(code, signature, this.otpTtl) const textContent = this.emailTextContent(code, signature, this.otpTtl) const htmlContent = this.emailHtmlContent(code, signature, this.otpTtl) // Send email await this.mailer.sendEmail({ from: this.emailFrom, to: email, subject, textContent, htmlContent, }) return { success: true, data: { otpId: otp.id, signature: otp.signature }, } } catch (error) { return { success: false, error: error as Error } } } async verifyOTP(payload: { email: string otpId: string code: string }): Promise> { // Validate payload with Zod const result = emailOTPAuthenticateDataSchema.safeParse(payload) if (!result.success) { return { success: false, error: new KenmonInvalidPayloadError(result.error.issues[0].message), } } const { email, otpId, code } = result.data try { // Fetch OTP from storage const otp = await this.otpStorage.getOTPById(otpId) if (!otp) { return { success: false, error: new KenmonEmailOTPError('not-found') } } // Verify OTP belongs to this email if (otp.email !== email) { return { success: false, error: new KenmonEmailOTPError('email-mismatch'), } } // Check if OTP has been used if (otp.used) { return { success: false, error: new KenmonEmailOTPError('already-used'), } } // Check if OTP has expired if (isAfter(new Date(), otp.expiresAt)) { return { success: false, error: new KenmonEmailOTPError('expired') } } // Verify OTP code if (otp.code !== code) { return { success: false, error: new KenmonEmailOTPError('invalid-code'), } } // Mark OTP as used await this.otpStorage.markOTPAsUsed(otpId) // Return identifier return { success: true, data: { type: 'email-otp', value: email, }, } } catch (error) { return { success: false, error: error as Error } } } private generateOTPCode(): string { const digits = '0123456789' let code = '' for (let i = 0; i < this.otpLength; i++) { code += digits[crypto.randomInt(0, 10)] } return code } }