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.tsBase 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
| Method | Signature | Description |
|---|---|---|
send | send(opts) => { sessionToken, expiresAt } | Check lock, cooldown, daily limit; generate code and hash with Bun.password; store in Redis; call _deliver() |
verifyByCode | verifyByCode(opts) => TVerifyResult | Resolve session, check lock, validate code hash, increment attempts on failure, lock if max exceeded, call _onVerified() on success |
resolveSession | resolveSession(opts) => TSessionData | Fetch session data from Redis by token |
Abstract Members
Subclasses must implement:
| Member | Purpose |
|---|---|
_redis | Injected 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 Pattern | Value | TTL |
|---|---|---|
{ns}:otp:{identifier} | Code hash, attempts, expiresAt | codeExpirySeconds |
{ns}:lock:{identifier} | Lockout flag | lockoutSeconds |
{ns}:session:{token} | Session data JSON | codeExpirySeconds |
{ns}:cooldown:{identifier} | Resend cooldown flag | resendCooldownSeconds |
{ns}:daily:{identifier} | Daily request counter | Until midnight |
Config Interface
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 viaIMailServicewith configurable template (appName,templateName,templateDataKey)._onVerified(sessionData)— returnsTOtpVerifyResult.- Config extends base with:
appName,templateName,templateDataKey.
PhoneOtpService
Source: src/services/otp/base/provider/phone-otp.service.ts
_deliver({ target, code })— sends SMS viaIOTPSender._onVerified(sessionData)— returnsTOtpVerifyResult.
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 })callsemailOtp.send()with namespacemfa: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 })callsphoneOtp.send()with namespacemfa:phone:verify_phone, no cooldown, no daily limit.- Registration path runs
_matchAndLinkCustomers(): finds unlinkedCustomerrecords 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 identifieruserId:target, namespacemfa:{provider}:link_account, no cooldown or daily limit.verifyLink({ provider, sessionToken, code, requestUserId })— verify OTP, owner check (result.userId === requestUserId), then upsertUserIdentifier.
REST Endpoints
| Endpoint | Auth | Service | Request Body | Response |
|---|---|---|---|---|
POST /auth/phone/request | None | VerifyPhoneService.sendOtp | { phone } | { sessionToken, expiresAt } |
POST /auth/phone/verify | None | VerifyPhoneService.verifyOtp | { sessionToken, otp } | { userId, isNewUser, roles, token } |
POST /auth/email/request | None | VerifyEmailService.sendOtp | { email } | { sessionToken, expiresAt } |
POST /auth/email/verify | None | VerifyEmailService.verifyOtp | { sessionToken, otp } | { userId, isNewUser, roles, token } |
POST /auth/me/phone/request | JWT | LinkAccountService.requestLink('phone') | { phone } | { sessionToken, expiresAt } |
POST /auth/me/phone/verify | JWT | LinkAccountService.verifyLink('phone') | { sessionToken, otp } | { scheme, identifier, verified } |
POST /auth/me/email/request | JWT | LinkAccountService.requestLink('email') | { email } | { sessionToken, expiresAt } |
POST /auth/me/email/verify | JWT | LinkAccountService.verifyLink('email') | { sessionToken, otp } | { scheme, identifier, verified } |
POST /auth/forgot-password/request | None | ForgotPasswordService.requestReset | { email } | { sessionToken, expiresAt } |
POST /auth/forgot-password/verify | None | ForgotPasswordService.verifyCode | { sessionToken, code } | { resetToken, expiresAt } |
POST /auth/forgot-password/reset | None | ForgotPasswordService.resetPassword | { resetToken, newCredential } | { message } |
Rate Limits
| Flow | Namespace | Expiry | Max Attempts | Lockout | Cooldown | Daily Limit |
|---|---|---|---|---|---|---|
| Verify email | mfa:email:verify_email | 10 min | 5 | 15 min | 60 s | 5/day |
| Verify phone | mfa:phone:verify_phone | 5 min | 5 | 10 min | -- | -- |
| Forgot password | mfa:email:forgot_password | 15 min | 5 | 30 min | 60 s | 5/day |
| Link email | mfa:email:link_account | 10 min | 5 | 15 min | -- | -- |
| Link phone | mfa:phone:link_account | 5 min | 5 | 10 min | -- | -- |
Error Codes
| Code | HTTP | Meaning |
|---|---|---|
CODE_INVALID | 400 | Wrong code or expired session |
CODE_EXPIRED | 400 | OTP TTL has passed |
MAX_ATTEMPTS_EXCEEDED | 429 | Hit max attempts, account now locked |
ACCOUNT_LOCKED | 429 | Too many failed attempts |
RATE_LIMIT_EXCEEDED | 429 | Cooldown or daily limit hit |
Types
Source: src/services/otp/types.ts
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;
};Related Pages
- Authentication -- Sign-in/up flow, JWKS JWT
- User Management -- UserService, identifier lifecycle
- RBAC -- Role hierarchy, PolicyDefinition
- Identity Overview -- Architecture, components