Skip to content

MFA & OTP v2.0.0

Source: src/services/otp/

The OTP subsystem provides a unified, Redis-backed one-time-password lifecycle used by every MFA flow in the identity service. A single abstract base class handles code generation, hashing, rate limiting, lockout, and session management. Concrete providers deliver codes via email or SMS, while business services implement domain-specific verification logic.

Architecture

File Structure

services/otp/
├── base/
│   ├── base-otp.service.ts              ← BaseOTPBasedMFAService (abstract)
│   ├── provider/
│   │   ├── email-otp.service.ts         ← EmailOtpService (delivery only)
│   │   ├── phone-otp.service.ts         ← PhoneOtpService (delivery only)
│   │   └── index.ts
│   └── index.ts
├── verify-email.service.ts              ← VerifyEmailService
├── verify-phone.service.ts              ← VerifyPhoneService
├── forgot-password.service.ts           ← ForgotPasswordService
├── link-account.service.ts              ← LinkAccountService
├── types.ts                             ← TOtpProvider, TOtpSessionData, TOtpVerifyResult
└── index.ts

Base Service — BaseOTPBasedMFAService

Source: src/services/otp/base/base-otp.service.ts

Abstract generic class that owns the full OTP lifecycle. Callers provide a namespace, config object, session data, and rate-limit flags.

Public API

MethodSignatureDescription
sendsend(opts) => { sessionToken, expiresAt }Check lock, cooldown, daily limit; generate code and hash with Bun.password; store in Redis; call _deliver()
verifyByCodeverifyByCode(opts) => TVerifyResultResolve session, check lock, validate code hash, increment attempts on failure, lock if max exceeded, call _onVerified() on success
resolveSessionresolveSession(opts) => TSessionDataFetch session data from Redis by token

Abstract Members

Subclasses must implement:

MemberPurpose
_redisInjected Redis helper instance
_deliver({ target, code, config })Send code to email or SMS
_onVerified(sessionData)Post-verification handler

Redis Key Schema

All keys are prefixed with the namespace parameter passed at send time.

Key PatternValueTTL
{ns}:otp:{identifier}Code hash, attempts, expiresAtcodeExpirySeconds
{ns}:lock:{identifier}Lockout flaglockoutSeconds
{ns}:session:{token}Session data JSONcodeExpirySeconds
{ns}:cooldown:{identifier}Resend cooldown flagresendCooldownSeconds
{ns}:daily:{identifier}Daily request counterUntil midnight

Config Interface

typescript
interface IBaseOTPBasedMFAConfig {
  codeLength: number;
  codeExpirySeconds: number;
  maxAttempts: number;
  lockoutSeconds: number;
  resendCooldownSeconds?: number;
  maxResendsPerDay?: number;
}

Providers

EmailOtpService

Source: src/services/otp/base/provider/email-otp.service.ts

  • _deliver({ target, code, config }) — sends via IMailService with configurable template (appName, templateName, templateDataKey).
  • _onVerified(sessionData) — returns TOtpVerifyResult.
  • Config extends base with: appName, templateName, templateDataKey.

PhoneOtpService

Source: src/services/otp/base/provider/phone-otp.service.ts

  • _deliver({ target, code }) — sends SMS via IOTPSender.
  • _onVerified(sessionData) — returns TOtpVerifyResult.

Design Principle

Providers never parse identifiers. They receive the actual email or phone number from sessionData.target.

Business Services

VerifyEmailService

Source: src/services/otp/verify-email.service.ts

Unauthenticated email sign-in and registration.

  • sendOtp({ email }) calls emailOtp.send() with namespace mfa:email:verify_email, cooldown enabled, daily limit enabled.
  • verifyOtp({ sessionToken, otp }) implements three-path logic depending on the state of the email in the database.

VerifyPhoneService

Source: src/services/otp/verify-phone.service.ts

Unauthenticated phone sign-in and registration with customer linking.

  • sendOtp({ phone }) calls phoneOtp.send() with namespace mfa:phone:verify_phone, no cooldown, no daily limit.
  • Registration path runs _matchAndLinkCustomers(): finds unlinked Customer records matching the phone and binds them to the new user and their organizer.

ForgotPasswordService

Source: src/services/otp/forgot-password.service.ts

Password reset with anti-enumeration protection.

LinkAccountService

Source: src/services/otp/link-account.service.ts

Authenticated flow to add an email or phone to an existing account.

  • requestLink({ provider, userId, target }) — conflict check (409 if target already taken) then sends OTP with composite identifier userId:target, namespace mfa:{provider}:link_account, no cooldown or daily limit.
  • verifyLink({ provider, sessionToken, code, requestUserId }) — verify OTP, owner check (result.userId === requestUserId), then upsert UserIdentifier.

REST Endpoints

EndpointAuthServiceRequest BodyResponse
POST /auth/phone/requestNoneVerifyPhoneService.sendOtp{ phone }{ sessionToken, expiresAt }
POST /auth/phone/verifyNoneVerifyPhoneService.verifyOtp{ sessionToken, otp }{ userId, isNewUser, roles, token }
POST /auth/email/requestNoneVerifyEmailService.sendOtp{ email }{ sessionToken, expiresAt }
POST /auth/email/verifyNoneVerifyEmailService.verifyOtp{ sessionToken, otp }{ userId, isNewUser, roles, token }
POST /auth/me/phone/requestJWTLinkAccountService.requestLink('phone'){ phone }{ sessionToken, expiresAt }
POST /auth/me/phone/verifyJWTLinkAccountService.verifyLink('phone'){ sessionToken, otp }{ scheme, identifier, verified }
POST /auth/me/email/requestJWTLinkAccountService.requestLink('email'){ email }{ sessionToken, expiresAt }
POST /auth/me/email/verifyJWTLinkAccountService.verifyLink('email'){ sessionToken, otp }{ scheme, identifier, verified }
POST /auth/forgot-password/requestNoneForgotPasswordService.requestReset{ email }{ sessionToken, expiresAt }
POST /auth/forgot-password/verifyNoneForgotPasswordService.verifyCode{ sessionToken, code }{ resetToken, expiresAt }
POST /auth/forgot-password/resetNoneForgotPasswordService.resetPassword{ resetToken, newCredential }{ message }

Rate Limits

FlowNamespaceExpiryMax AttemptsLockoutCooldownDaily Limit
Verify emailmfa:email:verify_email10 min515 min60 s5/day
Verify phonemfa:phone:verify_phone5 min510 min----
Forgot passwordmfa:email:forgot_password15 min530 min60 s5/day
Link emailmfa:email:link_account10 min515 min----
Link phonemfa:phone:link_account5 min510 min----

Error Codes

CodeHTTPMeaning
CODE_INVALID400Wrong code or expired session
CODE_EXPIRED400OTP TTL has passed
MAX_ATTEMPTS_EXCEEDED429Hit max attempts, account now locked
ACCOUNT_LOCKED429Too many failed attempts
RATE_LIMIT_EXCEEDED429Cooldown or daily limit hit

Types

Source: src/services/otp/types.ts

typescript
type TOtpProvider = 'email' | 'phone';

type TOtpSessionData = {
  identifier: string;   // Redis key identifier (email, phone, or userId:target)
  provider: TOtpProvider;
  target: string;       // Actual email or phone number
  userId?: string;      // Present for link_account flows
};

type TOtpVerifyResult = {
  provider: TOtpProvider;
  target: string;
  userId?: string;
};

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.