Implementing SMS OTP Verification with Fastify and Infobip
This guide provides a step-by-step walkthrough for building a production-ready SMS-based One-Time Password (OTP) or Two-Factor Authentication (2FA) system using Node.js, the Fastify framework, and the Infobip API. We will cover everything from initial project setup to deployment considerations and troubleshooting.
By following this guide, you will create a secure and reliable mechanism to verify user phone numbers via SMS, enhancing application security during registration, login, or critical actions.
Project Overview and Goals
What We're Building:
We will build a backend API using Fastify that exposes endpoints for:
- Initiating OTP: Sending an SMS containing a unique code to a user's phone number via Infobip.
- Verifying OTP: Validating the code submitted by the user against the one sent.
Problem Solved:
This implementation addresses the need for robust user verification, mitigating risks like account takeover, fake registrations, and unauthorized access by confirming possession of a specific phone number.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- Fastify: A high-performance, low-overhead web framework for Node.js. Chosen for its speed, extensibility, and developer-friendly features like built-in validation and logging.
- TypeScript: Adds static typing to JavaScript, improving code quality, maintainability, and developer productivity.
- Infobip: A global communication platform providing APIs for various channels, including SMS. We'll use their 2FA API specifically designed for OTP workflows.
- Prisma: A modern database toolkit for Node.js and TypeScript, simplifying database access, migrations, and type safety.
- PostgreSQL: A powerful, open-source relational database (though Prisma supports others).
- Axios: A promise-based HTTP client for making requests to the Infobip API.
- dotenv / @fastify/env: For managing environment variables securely.
- bcrypt: For securely hashing passwords (if implementing user registration).
- @fastify/rate-limit: To protect against brute-force attacks.
System Architecture:
+-------------+ +-----------------+ +-------------+ +-----------------+
| End User | <---> | Fastify API | <---> | Infobip API | <---> | User's Phone |
| (Browser/App)| | (Node.js) | | (2FA Service) | | (Receives SMS) |
+-------------+ +--------+--------+ +-------------+ +-----------------+
|
|
+-----+-----+
| Database |
| (PostgreSQL)|
+-----------+
- The user initiates an action requiring OTP (e.g., enters phone number).
- The Fastify API receives the request.
- The Fastify API calls the Infobip 2FA API to generate and send an OTP SMS.
- Infobip sends the SMS to the user's phone.
- The user receives the OTP and submits it back to the Fastify API.
- The Fastify API calls the Infobip 2FA API to verify the submitted PIN.
- Infobip confirms if the PIN is valid.
- The Fastify API responds to the user, granting or denying access/verification.
- OTP attempt data is stored/updated/deleted in the database.
Prerequisites:
- Node.js (v18 or later recommended) and npm/yarn installed. Install Node.js
- An active Infobip account. Create Infobip Account
- Access to a PostgreSQL database (local or cloud-hosted). Docker is a convenient way to run one locally. Install Docker
- Basic understanding of TypeScript, Node.js, REST APIs, and asynchronous programming.
Final Outcome:
A Fastify application with API endpoints capable of sending and verifying SMS OTPs using Infobip, including basic security measures and database integration.
1. Setting up the Project
Let's initialize our Fastify project with TypeScript, Prisma, and necessary dependencies.
1.1 Initialize Project & Install Core Dependencies:
Open your terminal and run the following commands:
# Create project directory and navigate into it
mkdir fastify-infobip-otp
cd fastify-infobip-otp
# Initialize Node.js project
npm init -y
# Install Fastify and TypeScript related dependencies
npm install fastify typescript @types/node ts-node-dev --save-dev
npm install @fastify/env @fastify/sensible # For env vars and sensible defaults
# Initialize TypeScript configuration
npx tsc --init --rootDir src --outDir dist --esModuleInterop --resolveJsonModule --lib esnext --module commonjs --allowJs true --noImplicitAny true
Explanation:
npm init -y
: Creates a basicpackage.json
.fastify
: The core framework.typescript
,@types/node
: TypeScript compiler and Node.js types.ts-node-dev
: Utility for running TypeScript projects with auto-reloading during development.@fastify/env
: Plugin for loading and validating environment variables.@fastify/sensible
: Adds useful defaults like error handling and headers.tsc --init ...
: Creates atsconfig.json
file configured for our project structure (src
for source,dist
for compiled output).
1.2 Configure package.json
Scripts:
Update the scripts
section in your package.json
:
{
"name": "fastify-infobip-otp",
"version": "1.0.0",
"description": "Infobip OTP implementation with Fastify",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node-dev --respawn --transpile-only --exit-child src/server.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"fastify",
"infobip",
"otp",
"2fa",
"typescript",
"prisma"
],
"author": "",
"license": "ISC"
// ... dependencies will be listed here ...
}
Explanation:
build
: Compiles TypeScript code to JavaScript in thedist
directory.start
: Runs the compiled JavaScript application (for production).dev
: Runs the application usingts-node-dev
for development, automatically restarting on file changes.test
: Placeholder for running automated tests (not implemented in detail in this guide).
1.3 Project Structure:
Create the following directories and files:
fastify-infobip-otp/
├── prisma/
│ └── schema.prisma # Database schema definition
├── src/
│ ├── plugins/ # Fastify plugins (env, sensible, etc.)
│ ├── modules/ # Feature modules (e.g., auth, otp)
│ │ └── otp/
│ │ ├── otp.routes.ts # API routes for OTP
│ │ └── otp.service.ts # Business logic for OTP (Infobip interaction)
│ ├── lib/ # Shared libraries (e.g., prisma client)
│ │ └── prisma.ts # Prisma client instance
│ └── server.ts # Main application entry point
├── .env # Environment variables (DO NOT COMMIT)
├── .env.example # Example environment variables
├── .gitignore # Files/folders to ignore in git
├── package.json
├── package-lock.json
└── tsconfig.json
Explanation:
- This structure promotes modularity and separation of concerns.
plugins
: Encapsulates Fastify plugin registrations.modules
: Contains specific application features (like OTP handling). Each module typically has routes, services, schemas, etc.lib
: Holds shared utilities like the configured Prisma client.
1.4 Setup Environment Variables:
Create .env.example
and .env
files.
# .env.example (for reference and version control)
DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
INFOBIP_BASE_URL="<YOUR_INFOBIP_API_BASE_URL>" # e.g., yyyyyy.api.infobip.com
INFOBIP_API_KEY="<YOUR_INFOBIP_API_KEY>"
INFOBIP_APPLICATION_ID="<YOUR_INFOBIP_2FA_APP_ID>"
INFOBIP_MESSAGE_ID="<YOUR_INFOBIP_2FA_MESSAGE_ID>"
# Optional: For user registration/auth
# BCRYPT_SALT_ROUNDS=10
# Optional: Port override
# PORT=8080
# Optional: OTP Time-to-live override (in minutes) - MUST MATCH INFOBIP CONFIG
# OTP_TTL_MINUTES=5
# .env (copy from .env.example and fill with actual values - DO NOT COMMIT THIS FILE)
cp .env.example .env
# Now edit .env with your actual credentials
Add .env
and dist
to your .gitignore
:
# .gitignore
node_modules
dist
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*
Obtaining Infobip Credentials:
- API Key & Base URL:
- Log in to your Infobip Portal.
- Navigate to the "Developers" section or your account settings.
- Your unique Base URL (e.g.,
xxxxx.api.infobip.com
) should be displayed. - Generate an API Key if you haven't already. Securely copy both the Base URL and the API Key.
- Path: Look for "API Keys" under account/developer settings. The Base URL is often shown prominently on the dashboard's developer section.
- Application ID & Message ID:
- These are specific to Infobip's 2FA service. You need to create a "2FA Application" and a "2FA Message Template" within that application via the Infobip Portal or their API.
- Portal Method:
- Navigate to Apps -> 2FA.
- Click "Create Application". Configure settings like PIN attempts, time-to-live (TTL - e.g., 300 seconds for 5 minutes), etc. Save it. Copy the generated
Application ID
. - Inside the application, click "Create Message Template". Define the message text (e.g.,
Your verification code is {{pin}}
), PIN type (Numeric), PIN length, etc. Save it. Copy the generatedMessage ID
.
- Path: Navigate to Apps -> 2FA. The "Create Application" and "Create Message Template" buttons are usually prominent here. The IDs are displayed after creation.
- API Method: We'll cover creating these via API later as an alternative/programmatic approach in the
otp.service.ts
section, but for initial setup, using the portal is easier.
1.5 Install Database (Prisma) & HTTP Client (Axios):
npm install @prisma/client axios
npm install prisma --save-dev
1.6 Initialize Prisma:
npx prisma init --datasource-provider postgresql
This creates the prisma/schema.prisma
file and updates .env
with a placeholder DATABASE_URL
. Ensure your actual DATABASE_URL
in .env
points to your running PostgreSQL instance.
1.7 Define Basic Prisma Schema:
Modify prisma/schema.prisma
:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // Loads from .env
}
// Optional: Basic User model if you need registration
// model User {
// id String @id @default(cuid())
// email String @unique
// password String // Store hashed password
// phone String? @unique // User's phone number - consider if uniqueness is truly required globally
// isPhoneVerified Boolean @default(false)
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// }
// Model to temporarily store OTP details (link pinId to a user/phone)
model OtpAttempt {
id String @id @default(cuid())
phone String // Phone number OTP was sent to
pinId String @unique // The pinId received from Infobip
expiresAt DateTime // When the OTP expires (based on Infobip TTL)
createdAt DateTime @default(now())
@@index([phone]) // Index for faster lookups by phone
}
Explanation:
- We define an
OtpAttempt
model to store thepinId
returned by Infobip when an OTP is sent. This is crucial for the verification step. We link it to the phone number and add an expiry time. - A basic
User
model is commented out – uncomment and adapt if you are building full user registration. Note the comment onphone @unique
: consider carefully if a phone number must be unique across all users in your application, as this might prevent legitimate scenarios like shared family numbers or number reuse over time. - An index is added to
OtpAttempt.phone
to speed up lookups during verification.
1.8 Create Initial Database Migration:
Ensure your database server is running and accessible using the DATABASE_URL
specified in .env
.
# Create the migration files and apply them to the database
npx prisma migrate dev --name init
This command:
- Creates SQL migration files in
prisma/migrations
. - Applies the migration to your database, creating the
OtpAttempt
table (andUser
table if uncommented). - Generates the Prisma Client based on your schema.
1.9 Setup Basic Fastify Server:
Create src/server.ts
:
// src/server.ts
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import envPlugin from './plugins/env'; // We will create this next
import otpRoutes from './modules/otp/otp.routes'; // We will create this later
import { prisma } from './lib/prisma'; // We will create this later
// Augment FastifyInstance type (usually done in a declaration file, e.g., types/fastify.d.ts,
// but can be done here for simplicity in this example)
declare module 'fastify' {
interface FastifyInstance {
prisma: typeof prisma; // Add prisma client to the instance type
config: any; // Add config from env plugin - adjust type if needed
}
}
async function buildServer() {
const server = Fastify({
logger: true, // Enable basic logging
}).withTypeProvider<TypeBoxTypeProvider>(); // Enable TypeBox for schema validation
// Register essential plugins
await server.register(envPlugin);
await server.register(sensible); // Provides .notFound(), .badRequest(), etc.
// Decorate server instance with prisma client for easy access in routes/plugins
// Do this *before* registering routes that need it.
server.decorate('prisma', prisma);
// Register application routes
await server.register(otpRoutes, { prefix: '/api/otp' });
// Basic health check route
server.get('/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
// Graceful shutdown hook
server.addHook('onClose', async () => {
server.log.info('Server shutting down...');
await prisma.$disconnect(); // Disconnect Prisma client
server.log.info('Prisma client disconnected.');
});
return server;
}
async function start() {
const server = await buildServer();
try {
// Use PORT from validated config, default handled in env schema
const port = server.config.PORT;
await server.listen({ port: port, host: '0.0.0.0' }); // Listen on all network interfaces
// Note: Fastify V4 listen logs automatically, custom console.log might be redundant
// Handle termination signals for graceful shutdown
['SIGINT', 'SIGTERM'].forEach((signal) => {
process.on(signal, async () => {
await server.close();
// process.exit is handled by Fastify's close hook now
});
});
} catch (err) {
server.log.error(err);
await prisma.$disconnect(); // Ensure disconnection on startup error
process.exit(1);
}
}
start();
1.10 Setup Environment Variable Loading:
First, install TypeBox dependencies:
npm install @sinclair/typebox @fastify/type-provider-typebox
Now, create src/plugins/env.ts
:
// src/plugins/env.ts
import fp from 'fastify-plugin';
import fastifyEnv, { FastifyEnvOptions } from '@fastify/env';
import { Static, Type } from '@sinclair/typebox';
// Define schema for environment variables using TypeBox
const EnvSchema = Type.Object({
DATABASE_URL: Type.String(),
INFOBIP_BASE_URL: Type.String({ format: 'uri' }), // Use 'uri' for URLs
INFOBIP_API_KEY: Type.String(),
INFOBIP_APPLICATION_ID: Type.String(),
INFOBIP_MESSAGE_ID: Type.String(),
PORT: Type.Optional(Type.Number({ default: 3000 })), // Optional PORT with default
OTP_TTL_MINUTES: Type.Optional(Type.Number({ default: 5 })) // Optional TTL with default
// BCRYPT_SALT_ROUNDS: Type.Optional(Type.Number({ default: 10 })) // Optional
});
// Declare type for the validated config object
type EnvConfig = Static<typeof EnvSchema>;
// Augment FastifyInstance to include the validated config
// Ensure this matches or is compatible with the augmentation in server.ts
declare module 'fastify' {
interface FastifyInstance {
config: EnvConfig;
}
}
export default fp(async (fastify) => {
const options: FastifyEnvOptions = {
confKey: 'config', // Access environment variables via `fastify.config`
schema: EnvSchema,
dotenv: true, // Load .env file
data: process.env // Pass process.env to be validated
};
await fastify.register(fastifyEnv, options);
fastify.log.info('Environment variables loaded and validated.');
}, { name: 'env-plugin' });
1.11 Setup Prisma Client Instance:
Create src/lib/prisma.ts
:
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
// Initialize Prisma Client
export const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'],
});
// Explicit connection is usually not required with Fastify's lifecycle.
// Prisma connects implicitly on the first query.
// The shutdown hook in server.ts handles disconnection via `prisma.$disconnect()`.
// If you encounter connection issues early during startup, uncommenting this
// explicit connection call *might* help surface errors sooner, but it's
// generally not necessary.
// async function connectPrisma() {
// try {
// await prisma.$connect();
// console.log('Prisma client connected explicitly.');
// } catch (e) {
// console.error('Failed to explicitly connect Prisma client:', e);
// process.exit(1);
// }
// }
// connectPrisma();
Project Setup Complete:
At this point, you have a basic Fastify application structure with TypeScript, environment variable loading, Prisma configured, and the initial database migration applied. You can run npm run dev
to start the development server. It won't do much yet beyond a health check, but it confirms the basic setup is working.
2. Implementing Core Functionality (OTP Service)
Now, let's implement the service that interacts with the Infobip 2FA API.
Create src/modules/otp/otp.service.ts
:
// src/modules/otp/otp.service.ts
import axios, { AxiosInstance, AxiosError } from 'axios';
import { prisma } from '../../lib/prisma';
import { FastifyInstance } from 'fastify'; // Import FastifyInstance type
// Interface for configuration passed to the service
interface InfobipConfig {
baseURL: string;
apiKey: string;
applicationId: string;
messageId: string;
otpTtlMinutes: number; // Added TTL config
}
// Interface for successful OTP send response from Infobip
interface InfobipSendResponse {
pinId: string;
to: string;
ncStatus: string;
smsStatus: string;
}
// Interface for successful OTP verify response from Infobip
interface InfobipVerifyResponse {
pinId: string;
verified: boolean;
msisdn: string;
attemptsRemaining: number;
}
// Interface for Infobip API error response
interface InfobipErrorResponse {
requestError?: {
serviceException?: {
messageId: string;
text: string;
validationErrors?: Record<string_ string>;
};
};
}
export class OtpService {
private axiosInstance: AxiosInstance;
private config: InfobipConfig;
private logger: FastifyInstance['log']; // Use Fastify logger
constructor(config: InfobipConfig, logger: FastifyInstance['log']) {
this.config = config;
this.logger = logger; // Store logger instance
this.axiosInstance = axios.create({
baseURL: config.baseURL,
headers: {
'Authorization': `App ${config.apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
// Optional: Add interceptors for logging or standardized error handling
this.axiosInstance.interceptors.response.use(
response => response,
(error: AxiosError<InfobipErrorResponse>) => {
const errorData = error.response?.data?.requestError?.serviceException;
this.logger.error({
msg: 'Infobip API Error',
status: error.response?.status,
code: errorData?.messageId,
text: errorData?.text,
url: error.config?.url,
}, `Infobip API request failed: ${errorData?.text || error.message}`);
// Re-throw a simplified error or handle specific codes
return Promise.reject(new Error(errorData?.text || 'Infobip API request failed'));
}
);
}
/**
* Sends an OTP to the specified phone number using Infobip 2FA API.
* Stores the pinId and expiry in the database.
* @param phone - The recipient's phone number in E.164 format (e.g., +12223334444).
* @param language - Optional language code for the message template (if configured in Infobip).
* @returns The pinId generated by Infobip.
* @throws Error if the API call fails or database operation fails.
*/
async sendOtp(phone: string, language?: string): Promise<string> {
const url = `/2fa/2/pin`;
const requestBody = {
applicationId: this.config.applicationId,
messageId: this.config.messageId,
// Note: Alphanumeric Sender IDs like ""InfoSMS"" often require pre-registration
// with Infobip and might be subject to country-specific regulations or restrictions.
// Use a standard numeric sender if unsure or if unregistered.
from: ""InfoSMS"",
to: phone,
...(language && { language }), // Conditionally add language if provided
};
this.logger.info(`Sending OTP to ${phone} using AppID: ${this.config.applicationId}, MsgID: ${this.config.messageId}`);
try {
const response = await this.axiosInstance.post<InfobipSendResponse>(url, requestBody);
const { pinId } = response.data;
// --- Database Interaction ---
// Calculate expiry time based on configured TTL.
// **IMPORTANT**: This TTL *must* match the 'PIN time to live' setting
// configured in your Infobip 2FA Application settings for verification to work correctly.
const expiresAt = new Date(Date.now() + this.config.otpTtlMinutes * 60 * 1000);
// **Upsert Logic Consideration**: Using `upsert` with `where: { phone: phone }`
// assumes you want to replace any existing attempt for this phone number.
// However, `phone` is not defined as unique in the `OtpAttempt` schema.
// This might work in Prisma under certain conditions but isn't standard SQL behavior
// and relies on finding exactly one record or none.
// A more robust approach for the ""invalidate previous OTP"" pattern might involve:
// 1. Finding the existing active attempt by phone.
// 2. If found, deleting it or updating it specifically by its unique ID/pinId.
// 3. Creating the new attempt.
// The current `upsert` is simpler but carries this caveat. It aligns with the
// verification logic which retrieves the *latest* pinId for the phone.
// **Correction:** Prisma `upsert` requires a unique field in `where`. Since `pinId` is unique,
// we should ideally find by phone, delete if exists, then create.
// Or, if we want to allow resends, the verification logic needs to find the *latest* valid pinId for the phone.
// Let's stick to the simple upsert-by-phone for now, assuming it finds/replaces the single intended record,
// but acknowledge this might need refinement based on exact requirements (e.g., using `deleteMany` then `create`).
// **Alternative/Safer Upsert:** Use `pinId` which is unique. But we don't know the *old* pinId here.
// Let's proceed with the original intent, assuming `phone` acts as a pseudo-key for the *latest* attempt.
// **Revised Approach:** Delete existing attempts for the phone, then create the new one.
await prisma.$transaction([
prisma.otpAttempt.deleteMany({
where: { phone: phone }
}),
prisma.otpAttempt.create({
data: {
phone,
pinId,
expiresAt,
}
})
]);
// Original upsert logic (kept for reference, but replaced by transaction above):
// await prisma.otpAttempt.upsert({
// where: { phone: phone }, // Potential issue: 'phone' is not unique. See comment above.
// create: {
// phone,
// pinId,
// expiresAt,
// },
// update: {
// pinId, // Update pinId if resent before verification
// expiresAt,
// createdAt: new Date(), // Reset creation time on resend
// },
// });
this.logger.info(`Successfully sent OTP to ${phone}, pinId: ${pinId} stored.`);
// --- End Database Interaction ---
return pinId;
} catch (error: any) {
this.logger.error(`Failed to send OTP to ${phone}: ${error.message}`);
// Rethrow or handle specific errors (e.g., invalid number format)
throw new Error(`Failed to send OTP: ${error.message}`);
}
}
/**
* Verifies the submitted PIN against the pinId using Infobip 2FA API.
* Removes the OTP attempt record from the database upon successful verification.
* @param pinId - The ID received when the OTP was sent.
* @param pin - The PIN code entered by the user.
* @returns True if verification is successful, false otherwise.
* @throws Error if the API call fails unexpectedly.
*/
async verifyOtp(pinId: string, pin: string): Promise<boolean> {
const url = `/2fa/2/pin/${pinId}/verify`;
const requestBody = { pin };
this.logger.info(`Verifying OTP for pinId: ${pinId}`);
try {
// --- Optional: Pre-check in DB ---
const otpAttempt = await prisma.otpAttempt.findUnique({
where: { pinId },
});
if (!otpAttempt) {
this.logger.warn(`Verification attempt for non-existent or already verified pinId: ${pinId}`);
return false; // PinId doesn't exist in our records (or was already cleaned up)
}
if (new Date() > otpAttempt.expiresAt) {
this.logger.warn(`Verification attempt for expired pinId: ${pinId}`);
// Clean up the expired record to prevent repeated checks
await prisma.otpAttempt.delete({ where: { pinId } }).catch(e => this.logger.error(e, ""Failed to cleanup expired OTP attempt""));
return false; // OTP expired based on our stored expiry
}
// --- End Pre-check ---
const response = await this.axiosInstance.post<InfobipVerifyResponse>(url, requestBody);
const { verified, attemptsRemaining } = response.data;
this.logger.info(`Verification result for pinId ${pinId}: ${verified}, attempts remaining: ${attemptsRemaining}`);
if (verified) {
// --- Database Interaction ---
// Clean up the successful OTP attempt record
await prisma.otpAttempt.delete({
where: { pinId },
});
this.logger.info(`Successfully verified and removed OTP record for pinId: ${pinId}`);
// --- End Database Interaction ---
// Optional: Update User.isPhoneVerified if applicable
// await prisma.user.update({ where: { phone: otpAttempt.phone }, data: { isPhoneVerified: true }});
} else {
// Optional: Handle lockout after too many failed attempts if attemptsRemaining hits 0
if (attemptsRemaining <= 0) {
this.logger.warn(`OTP verification failed for pinId ${pinId} - maximum attempts reached.`);
// Implement lockout logic if needed (e.g._ block phone number temporarily)
// Clean up the record as it's now invalid according to Infobip
await prisma.otpAttempt.delete({ where: { pinId } }).catch(e => this.logger.error(e, ""Failed to cleanup exhausted OTP attempt""));
}
}
return verified;
} catch (error: any) {
// Specific error handling based on Infobip response codes might be needed
// e.g., PIN_NOT_FOUND (404), WRONG_PIN (400), PIN_EXPIRED (400)
const axiosError = error as AxiosError<InfobipErrorResponse>;
const errCode = axiosError.response?.data?.requestError?.serviceException?.messageId;
const errText = axiosError.response?.data?.requestError?.serviceException?.text;
this.logger.error(`Failed to verify OTP for pinId ${pinId}: ${errText || error.message} (Code: ${errCode})`);
// If PIN was wrong/expired/not found according to Infobip, still treat as verification failure
if (axiosError.response?.status === 400 || axiosError.response?.status === 404) {
// Optionally clean up the attempt if Infobip says it's invalid/expired/not found
// Checking the code helps distinguish from other 400/404 errors
if (errCode === 'PIN_EXPIRED' || errCode === 'PIN_NOT_FOUND' || errCode === 'WRONG_PIN') {
// We might want to keep WRONG_PIN attempts until attemptsRemaining=0
// Let's only clean up EXPIRED or NOT_FOUND here for certainty.
if (errCode === 'PIN_EXPIRED' || errCode === 'PIN_NOT_FOUND') {
// Ensure pinId exists before attempting delete, handle potential race conditions
const attemptExists = await prisma.otpAttempt.findUnique({ where: { pinId }, select: { id: true } });
if (attemptExists) {
await prisma.otpAttempt.delete({ where: { pinId } }).catch(e => this.logger.error(e, ""Failed to cleanup invalid/expired OTP attempt""));
}
}
}
return false; // Indicate verification failed
}
// For other errors (network, server issues), rethrow to be handled as 500s
throw new Error(`Failed to verify OTP: ${errText || error.message}`);
}
}
// --- Optional: Methods for managing Infobip 2FA Applications and Templates via API ---
/**
* Creates a 2FA Application via Infobip API.
* NOTE: Usually done once via the Infobip Portal.
* @param name - Name of the application.
* @param configuration - Application settings (e.g., { pinAttempts: 5, pinTimeToLive: ""300s"", ... }).
*/
async createApplication(name: string, configuration: any) {
const url = `/2fa/2/applications`;
const requestBody = { name, configuration, enabled: true };
this.logger.info(`Attempting to create 2FA Application: ${name}`);
try {
const response = await this.axiosInstance.post(url, requestBody);
this.logger.info(`Successfully created 2FA Application ${name}`, response.data);
return response.data; // Contains the new applicationId
} catch (error: any) {
this.logger.error(`Failed to create 2FA Application ${name}: ${error.message}`);
throw error;
}
}
/**
* Creates a 2FA Message Template within an application via Infobip API.
* NOTE: Usually done once via the Infobip Portal.
* @param applicationId - The ID of the application to add the template to.
* @param messageText - The template text including {{pin}} placeholder (e.g., ""Your code is {{pin}}"").
* @param pinType - e.g., 'NUMERIC'.
* @param pinLength - e.g., 6.
*/
async createMessageTemplate(applicationId: string, messageText: string, pinType: string, pinLength: number) {
const url = `/2fa/2/applications/${applicationId}/messages`;
// Example: Including senderId (must be approved by Infobip if alphanumeric)
const requestBody = {
messageText,
pinType,
pinLength,
// senderId: ""InfoSMS"", // Optional: Specify sender ID
// language: ""en"", // Optional: Specify language
// regional: { indiaDlt: { contentTemplateId: ""..."", principalEntityId: ""..."" } } // Optional: Regional settings
};
this.logger.info(`Attempting to create Message Template for AppID: ${applicationId}`);
try {
const response = await this.axiosInstance.post(url, requestBody);
this.logger.info(`Successfully created Message Template for AppID ${applicationId}`, response.data);
return response.data; // Contains the new messageId
} catch (error: any) {
this.logger.error(`Failed to create Message Template for AppID ${applicationId}: ${error.message}`);
throw error;
}
}
}