Two-factor authentication (2FA), often using One-Time Passwords (OTP) sent via SMS, adds a critical layer of security to user verification processes. This guide provides a complete walkthrough for building a robust SMS OTP verification system using Node.js, the Fastify framework, and the Vonage Verify API.
We will build a backend API with two primary endpoints: one to request an OTP sent to a user's phone number and another to verify the OTP entered by the user. This guide covers everything from project setup to deployment considerations, focusing on production readiness.
Project Overview and Goals
What We're Building:
A backend API service built with Node.js and Fastify that:
- Accepts a user's phone number.
- Uses the Vonage Verify API to send an SMS containing a unique OTP to that number.
- Provides an endpoint to verify the OTP submitted by the user against the one sent by Vonage.
Problem Solved:
This implementation addresses the need for a secure, out-of-band verification method commonly used during registration, login, or sensitive actions within an application. It mitigates risks associated with compromised passwords by requiring possession of the user's registered phone.
Technologies Used:
- Node.js: A JavaScript runtime environment for building server-side applications.
- 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.
- Vonage Verify API: A service that handles the complexities of OTP generation, delivery (SMS, voice), and verification logic. We use this to avoid building and maintaining our own OTP system.
dotenv
: A module to load environment variables from a.env
file intoprocess.env
.@vonage/server-sdk
: The official Vonage Node.js SDK for interacting with Vonage APIs.
System Architecture:
The system involves a user interacting with a client application, which communicates with our Fastify API. The Fastify API, in turn, uses the Vonage Verify API to handle SMS OTP sending and verification. The typical flow is: User provides phone number -> Client App sends number to Fastify API -> Fastify API asks Vonage to send OTP -> Vonage sends SMS to User -> User enters OTP in Client App -> Client App sends OTP and request ID to Fastify API -> Fastify API asks Vonage to verify -> Vonage confirms/denies -> Fastify API informs Client App -> Client App shows result to User.
Prerequisites:
- Node.js and npm (or yarn): Ensure Node.js (v16 or later recommended) and npm/yarn are installed. Download Node.js
- Vonage API Account: Required to get API credentials. Sign up for Vonage.
- Vonage API Key and Secret: Found on your Vonage API Dashboard after signing up.
- (Optional) Vonage CLI: Useful for managing Vonage applications and numbers. Install via
npm install -g @vonage/cli
. - Basic understanding of Node.js, APIs, and asynchronous JavaScript.
Expected Outcome:
A functional Fastify API service capable of sending and verifying SMS OTPs via Vonage, ready for integration into a larger application. The service will include basic security, error handling, and logging.
1. Setting Up the Project
Let's initialize our Node.js project and install Fastify along with necessary dependencies.
1. Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
mkdir fastify-vonage-otp
cd fastify-vonage-otp
2. Initialize npm Project:
npm init -y
This creates a package.json
file with default settings.
3. Install Dependencies:
We need Fastify, the Vonage SDK, dotenv
for environment variables, and pino-pretty
for development logging.
npm install fastify @vonage/server-sdk dotenv pino-pretty
fastify
: The core web framework.@vonage/server-sdk
: The official Vonage Node.js library.dotenv
: Loads environment variables from a.env
file.pino-pretty
: Makes Fastify's default JSON logs human-readable during development.
4. Configure package.json
for Development:
Add a dev
script to package.json
to run the server with readable logs using pino-pretty
.
{
""name"": ""fastify-vonage-otp"",
""version"": ""1.0.0"",
""description"": """",
""main"": ""src/server.js"",
""scripts"": {
""start"": ""node src/server.js"",
""dev"": ""node src/server.js | pino-pretty"",
""test"": ""echo \""Error: no test specified\"" && exit 1""
},
""keywords"": [],
""author"": """",
""license"": ""ISC"",
""dependencies"": {
""@vonage/server-sdk"": ""^..."",
""dotenv"": ""^..."",
""fastify"": ""^..."",
""pino-pretty"": ""^...""
}
}
(Note: Replace ^...
with actual installed versions if needed, but npm install
handles this.)
5. Create Project Structure:
Organize the project for clarity:
mkdir src
mkdir src/routes
touch src/server.js
touch .env
touch .gitignore
src/
: Contains the main application code.src/routes/
: Will hold our API route definitions.src/server.js
: The main entry point for the Fastify application..env
: Stores sensitive information like API keys (DO NOT commit this file)..gitignore
: Specifies intentionally untracked files that Git should ignore.
6. Configure .gitignore
:
Add node_modules
and .env
to your .gitignore
file to prevent committing them.
# .gitignore
node_modules/
.env
*.log
7. Set Up Environment Variables:
Open the .env
file and add your Vonage API credentials and a brand name for the SMS message.
# .env
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
VONAGE_BRAND_NAME=""YourAppName"" # Keep it short, SMS length limits apply
PORT=3000 # Optional: Define the port for the server
Replace YOUR_API_KEY
and YOUR_API_SECRET
with the actual credentials from your Vonage API Dashboard.
8. Basic Fastify Server Setup:
Create the initial server configuration in src/server.js
.
// src/server.js
// Load environment variables early
require('dotenv').config();
const fastify = require('fastify')({
logger: true // Enable built-in Pino logger
});
// --- Plugin Registration ---
// Example: Register formbody parser if needed later for simpler forms
// fastify.register(require('@fastify/formbody'));
// --- Vonage Client Setup (Placeholder) ---
// We will add Vonage initialization here later
// --- Route Registration (Placeholder) ---
// We will register our OTP routes here
// --- Error Handling (Placeholder) ---
// We will add custom error handling here
// --- Start Server ---
const start = async () => {
try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Explanation:
require('dotenv').config();
: Loads variables from.env
intoprocess.env
. Crucially done before other modules might need them.fastify({ logger: true })
: Initializes Fastify with logging enabled. Pino is used by default.fastify.listen()
: Starts the server. We listen on0.0.0.0
to make it accessible within Docker containers or VMs if needed later.
You can now run npm run dev
in your terminal. You should see log output indicating the server is running, likely on port 3000. Stop the server with Ctrl+C
.
2. Integrating with Vonage
Now, let's initialize the Vonage SDK client and make it available within our Fastify application.
1. Initialize Vonage Client:
Update src/server.js
to create and configure the Vonage client using the environment variables. We'll use Fastify's decorate
utility to make the client accessible within route handlers via request.vonage
or fastify.vonage
.
// src/server.js
require('dotenv').config();
const fastify = require('fastify')({
logger: true
});
const { Vonage } = require('@vonage/server-sdk'); // Import Vonage
// --- Plugin Registration ---
// fastify.register(require('@fastify/formbody'));
// --- Vonage Client Setup ---
const vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
});
// Decorate Fastify instance/request with the Vonage client
fastify.decorate('vonage', vonage);
// --- Route Registration (Placeholder) ---
// We will register our OTP routes here
// --- Error Handling (Placeholder) ---
// We will add custom error handling here
// --- Start Server ---
const start = async () => {
// ... (rest of the start function remains the same)
try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Explanation:
- We import the
Vonage
class from the SDK. - We instantiate
Vonage
using the API key and secret loaded from.env
. fastify.decorate('vonage', vonage)
adds thevonage
instance to Fastify's application context, making it easily accessible in routes and plugins.
3. Implementing the OTP Flow (API Layer)
We'll create two API endpoints within a dedicated route file:
POST /request-otp
: Initiates the OTP process.POST /verify-otp
: Verifies the submitted OTP.
1. Create Route File:
Create a new file src/routes/otp.js
.
touch src/routes/otp.js
2. Define Routes:
Add the route logic to src/routes/otp.js
. We'll use Fastify's schema validation for request bodies.
// src/routes/otp.js
async function otpRoutes(fastify, options) {
// Schema for the /request-otp endpoint body
const requestOtpSchema = {
body: {
type: 'object',
required: ['phoneNumber'],
properties: {
phoneNumber: { type: 'string', description: 'User phone number in E.164 format (e.g., +14155552671)' }
},
additionalProperties: false // Disallow extra fields
}
};
// Schema for the /verify-otp endpoint body
const verifyOtpSchema = {
body: {
type: 'object',
required: ['requestId', 'code'],
properties: {
requestId: { type: 'string', description: 'The request ID received from /request-otp' },
code: { type: 'string', minLength: 4, maxLength: 6, description: 'The OTP code entered by the user' }
},
additionalProperties: false // Disallow extra fields
}
};
// --- Endpoint: Request OTP ---
fastify.post('/request-otp', { schema: requestOtpSchema }, async (request, reply) => {
const { phoneNumber } = request.body;
const vonage = fastify.vonage; // Access Vonage client via decorator
const brand = process.env.VONAGE_BRAND_NAME || 'MyApp'; // Use brand from .env or default
request.log.info(`Requesting OTP for phone number: ${phoneNumber}`);
try {
const response = await vonage.verify.start({
number: phoneNumber,
brand: brand,
// code_length: '6' // Optional: Uncomment to request a 6-digit code (default is 4)
// workflow_id: 6 // Optional: Specify workflow (e.g., 6 for SMS -> TTS -> TTS)
});
if (response.status === '0') {
request.log.info(`OTP request successful, requestId: ${response.request_id}`);
return reply.send({ success: true, requestId: response.request_id });
} else {
// Non-zero status indicates an error from Vonage before sending
request.log.error(`Vonage verify start error for ${phoneNumber}: Status ${response.status} - ${response.error_text}`);
// Use Fastify's error handling (we'll improve this later)
return reply.status(500).send({ success: false, message: response.error_text || 'Failed to initiate OTP verification.' });
}
} catch (error) {
request.log.error({ err: error, phoneNumber }, 'Error calling Vonage verify start API');
// Handle network errors or SDK issues
return reply.status(500).send({ success: false, message: 'An internal server error occurred while requesting OTP.' });
}
});
// --- Endpoint: Verify OTP ---
fastify.post('/verify-otp', { schema: verifyOtpSchema }, async (request, reply) => {
const { requestId, code } = request.body;
const vonage = fastify.vonage; // Access Vonage client
request.log.info(`Verifying OTP for requestId: ${requestId}`);
try {
const response = await vonage.verify.check(requestId, code);
if (response.status === '0') {
// Status '0' means successful verification
request.log.info(`OTP verification successful for requestId: ${requestId}`);
return reply.send({ success: true, message: 'OTP verified successfully.' });
} else {
// Handle specific Vonage error statuses
request.log.warn(`OTP verification failed for requestId ${requestId}: Status ${response.status} - ${response.error_text}`);
let userMessage = 'OTP verification failed.';
let statusCode = 400; // Bad Request by default for verification failures
// Customize messages based on common statuses (refer to Vonage docs for full list)
switch (response.status) {
case '6': // The code provided does not match the expected value
userMessage = 'Invalid or incorrect OTP code provided.';
break;
case '16': // The request specified by the request_id has already been verified
userMessage = 'This request has already been verified.';
statusCode = 409; // Conflict
break;
case '17': // The wrong code was provided too many times
userMessage = 'Too many incorrect attempts. Please request a new OTP.';
break;
case '101': // No response found (e.g., expired request_id)
userMessage = 'Verification request not found or expired. Please request a new OTP.';
statusCode = 404; // Not Found
break;
default:
userMessage = response.error_text || 'OTP verification failed.';
}
return reply.status(statusCode).send({ success: false, message: userMessage, errorCode: response.status });
}
} catch (error) {
// Handle potential SDK errors or network issues during check
request.log.error({ err: error, requestId }, 'Error calling Vonage verify check API');
// Check if the error is a Vonage specific error structure (might vary by SDK version)
// Note: The structure of Vonage SDK errors (`error.body`) can change between versions. Always consult the documentation for the specific SDK version you are using.
if (error.body && error.body.status && error.body.error_text) {
request.log.error(`Vonage API Error during check: Status ${error.body.status} - ${error.body.error_text}`);
return reply.status(error.statusCode || 500).send({ success: false, message: error.body.error_text || 'An internal error occurred during OTP verification.' });
}
return reply.status(500).send({ success: false, message: 'An internal server error occurred during OTP verification.' });
}
});
}
module.exports = otpRoutes;
Explanation:
otpRoutes(fastify, options)
: Standard Fastify plugin structure.- Schemas (
requestOtpSchema
,verifyOtpSchema
): Define the expected structure and types for the request bodies. Fastify automatically validates incoming requests against these schemas and returns a 400 Bad Request error if validation fails.additionalProperties: false
prevents unexpected fields. /request-otp
:- Retrieves
phoneNumber
from the validatedrequest.body
. - Accesses the decorated
vonage
client. - Calls
vonage.verify.start()
with the phone number and brand name. - Crucially: Checks the
response.status
. Astatus
of'0'
means Vonage accepted the request and will attempt to send the SMS. Other statuses mean an error occurred before sending (e.g., invalid number format, throttling). - Returns the
requestId
on success, which the client needs for the verification step. - Includes basic
try...catch
for network/SDK errors.
- Retrieves
/verify-otp
:- Retrieves
requestId
andcode
from the validatedrequest.body
. - Calls
vonage.verify.check()
with therequestId
and the user-providedcode
. - Checks
response.status
:'0'
means the code was correct. - Handles common non-zero statuses (invalid code, already verified, too many attempts, expired) by returning appropriate HTTP status codes (400, 409, 404) and user-friendly error messages.
- Includes
try...catch
for network/SDK errors during the check.
- Retrieves
3. Register Routes in Server:
Now, register these routes in src/server.js
.
// src/server.js
require('dotenv').config();
const fastify = require('fastify')({
logger: true
});
const { Vonage } = require('@vonage/server-sdk');
const otpRoutes = require('./routes/otp'); // Import the routes
// --- Plugin Registration ---
// fastify.register(require('@fastify/formbody'));
// --- Vonage Client Setup ---
const vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
});
fastify.decorate('vonage', vonage);
// --- Route Registration ---
fastify.register(otpRoutes, { prefix: '/api/v1' }); // Register OTP routes under /api/v1 prefix
// --- Error Handling (Placeholder) ---
// We will add custom error handling here
// --- Start Server ---
const start = async () => {
// ... (start function)
try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Explanation:
- We import the
otpRoutes
function. fastify.register(otpRoutes, { prefix: '/api/v1' })
: Registers all routes defined inotp.js
under the/api/v1
path prefix (e.g.,/api/v1/request-otp
). Using a prefix is good practice for API versioning.
At this point, you have the core API functionality. You can test it using curl
or a tool like Postman after starting the server (npm run dev
).
Testing with curl
:
Replace +1XXXXXXXXXX
with a real phone number you can receive SMS on (use E.164 format).
-
Request OTP:
curl -X POST http://localhost:3000/api/v1/request-otp \ -H 'Content-Type: application/json' \ -d '{""phoneNumber"": ""+1XXXXXXXXXX""}'
Expected Response (Success):
{""success"":true,""requestId"":""YOUR_UNIQUE_REQUEST_ID""}
You should receive an SMS with a 4-digit code (by default).
-
Verify OTP: Replace
YOUR_UNIQUE_REQUEST_ID
with the ID from the previous step andYOUR_OTP_CODE
with the code from the SMS.curl -X POST http://localhost:3000/api/v1/verify-otp \ -H 'Content-Type: application/json' \ -d '{""requestId"": ""YOUR_UNIQUE_REQUEST_ID"", ""code"": ""YOUR_OTP_CODE""}'
Expected Response (Success):
{""success"":true,""message"":""OTP verified successfully.""}
Expected Response (Incorrect Code):
{""success"":false,""message"":""Invalid or incorrect OTP code provided."",""errorCode"":""6""}
(Note: The HTTP status code will be 400 for incorrect code)
4. Implementing Proper Error Handling and Logging
Fastify's built-in logger (Pino) is already active. Let's add a centralized error handler to catch unhandled exceptions and format error responses consistently.
1. Add Custom Error Handler:
Update src/server.js
to include setErrorHandler
.
// src/server.js
require('dotenv').config();
const fastify = require('fastify')({
logger: true
});
const { Vonage } = require('@vonage/server-sdk');
const otpRoutes = require('./routes/otp');
// --- Plugin Registration ---
// fastify.register(require('@fastify/formbody'));
// --- Vonage Client Setup ---
const vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
});
fastify.decorate('vonage', vonage);
// --- Route Registration ---
fastify.register(otpRoutes, { prefix: '/api/v1' });
// --- Error Handling ---
fastify.setErrorHandler(function (error, request, reply) {
// Log the error
request.log.error(error);
// Check if it's a validation error from Fastify schema check
if (error.validation) {
reply.status(400).send({
success: false,
message: 'Validation error',
errors: error.validation, // Provides details on which fields failed
});
return;
}
// Check for specific error types you might throw or receive
// Example: if (error instanceof MyCustomError) { ... }
// Default fallback for other errors
// Use the status code from the error if available (e.g., from Vonage errors handled in routes)
const statusCode = error.statusCode || 500;
reply.status(statusCode).send({
success: false,
message: error.message || 'An unexpected internal server error occurred.',
// Avoid leaking stack traces in production environments
...(process.env.NODE_ENV !== 'production' && { stack: error.stack })
});
});
// --- Start Server ---
const start = async () => {
// ... (start function)
try {
const port = process.env.PORT || 3000;
await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
fastify.log.info(`Server listening on port ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Explanation:
fastify.setErrorHandler
: Registers a function to handle errors that occur during request processing after the initial routing but before a reply is sent, or errors explicitly passed toreply.send(error)
.- It logs the full error using
request.log.error(error)
. - It checks if the error is a Fastify validation error (
error.validation
) and returns a structured 400 response. - It provides a generic response for other unhandled errors, using the error's
statusCode
andmessage
if available (falling back to 500). - Important: It conditionally includes the stack trace only if
NODE_ENV
is not 'production' to avoid leaking sensitive information.
Logging:
- Fastify automatically logs incoming requests and outgoing responses.
- We added specific logging within the route handlers (
request.log.info
,request.log.error
,request.log.warn
) to provide context about the OTP flow. - During development (
npm run dev
),pino-pretty
formats these JSON logs nicely. In production, you'd typically pipe the raw JSON logs to a log management system (e.g., Datadog, ELK stack, Splunk) for analysis and alerting.
5. Adding Security Features
Security is paramount for an authentication mechanism.
1. Rate Limiting:
Protect against brute-force attacks on both requesting and verifying OTPs.
-
Install Rate Limiter Plugin:
npm install @fastify/rate-limit
-
Register and Configure: Add this to the ""Plugin Registration"" section in
src/server.js
.// src/server.js // ... other imports require('dotenv').config(); // Ensure this is at the top const fastify = require('fastify')({ logger: true }); const { Vonage } = require('@vonage/server-sdk'); const otpRoutes = require('./routes/otp'); // --- Plugin Registration --- fastify.register(require('@fastify/rate-limit'), { max: 100, // Max requests per windowMs per IP (adjust globally) timeWindow: '1 minute', // Optional: customize error response errorResponseBuilder: function (req, context) { return { success: false, message: `Rate limit exceeded, please try again after ${context.after}`, code: 'RATE_LIMIT_EXCEEDED', retryAfter: context.ttl, // Time-to-live seconds for the ban } }, // Optional: Key generator (e.g., use userId if authenticated) // keyGenerator: function (req) { return req.headers['x-real-ip'] || req.ip } // Default is IP }); // Register Helmet here too (see step 4 below) // fastify.register(require('@fastify/helmet')); // --- Vonage Client Setup --- const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET }); fastify.decorate('vonage', vonage); // --- Route Registration --- fastify.register(otpRoutes, { prefix: '/api/v1' }); // --- Error Handling --- // ... (setErrorHandler from Section 4) fastify.setErrorHandler(function (error, request, reply) { request.log.error(error); if (error.validation) { reply.status(400).send({ success: false, message: 'Validation error', errors: error.validation }); return; } const statusCode = error.statusCode || 500; reply.status(statusCode).send({ success: false, message: error.message || 'An unexpected internal server error occurred.', ...(process.env.NODE_ENV !== 'production' && { stack: error.stack }) }); }); // --- Start Server --- const start = async () => { try { const port = process.env.PORT || 3000; await fastify.listen({ port: port, host: '0.0.0.0' }); fastify.log.info(`Server listening on port ${fastify.server.address().port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();
-
Apply Specific Limits (Optional but Recommended): You can override the global settings within specific route options in
src/routes/otp.js
. It's wise to have stricter limits on OTP requests than general API usage.// src/routes/otp.js // ... schemas ... async function otpRoutes(fastify, options) { // --- Endpoint: Request OTP --- fastify.post('/request-otp', { schema: requestOtpSchema, config: { // Add rate limit config specific to this route rateLimit: { max: 5, // Allow only 5 requests per hour per key timeWindow: '1 hour', // WARNING: Using `phoneNumber` directly in `keyGenerator` can block legitimate users sharing IPs (e.g., behind NAT). // This approach is generally safer *only if* user authentication happens *before* the OTP request. // Otherwise, IP-based limiting (`req.ip`) is usually the safer default starting point. Combining IP and phone number is another option. // keyGenerator: function (req) { return req.body.phoneNumber || req.ip } } } }, async (request, reply) => { // ... route handler logic ... }); // --- Endpoint: Verify OTP --- fastify.post('/verify-otp', { schema: verifyOtpSchema, config: { // Add rate limit config specific to this route rateLimit: { max: 10, // Allow slightly more verification attempts per key timeWindow: '15 minutes' // Keying by requestId might be useful here, combined with IP // keyGenerator: function (req) { return `${req.ip}-${req.body.requestId}` } } } }, async (request, reply) => { // ... route handler logic ... }); } module.exports = otpRoutes;
Explanation:
@fastify/rate-limit
: Provides robust rate limiting based on IP address by default.max
,timeWindow
: Control how many requests are allowed within a specific timeframe.errorResponseBuilder
: Customizes the response when the limit is hit.keyGenerator
: Allows using factors other than IP (like phone number,requestId
, or authenticated user ID) for more granular limiting. Use with caution, as poorly chosen keys can block legitimate users. IP-based is often the simplest starting point.- Route-specific
config.rateLimit
overrides the global settings for finer control.
2. Input Validation and Sanitization:
- Fastify's schema validation (used in Section 3) handles basic input validation (checking types, required fields, lengths). This prevents many common injection-style attacks by ensuring data conforms to expectations before it's processed.
- For phone numbers, Vonage performs its own validation, but using a library like
google-libphonenumber
on the backend before sending to Vonage could provide earlier, more specific feedback to the user about formatting issues.
3. Secure Handling of Secrets:
- Environment Variables: API keys are correctly stored in
.env
and loaded viadotenv
. .gitignore
: The.env
file is in.gitignore
to prevent accidental commits.- Production: In production environments (like Docker, PaaS, servers), use the hosting provider's mechanism for managing environment variables securely (e.g., secrets management tools, platform environment variable settings). Do not deploy
.env
files directly to production servers.
4. Other Considerations:
- HTTPS: Always run your API over HTTPS in production to encrypt data in transit. Use a reverse proxy like Nginx or Caddy, or platform-provided SSL termination (e.g., AWS ELB, Heroku).
- Helmet: Consider using
@fastify/helmet
to set various security-related HTTP headers (likeX-Frame-Options
,Strict-Transport-Security
).Register it innpm install @fastify/helmet
src/server.js
within the ""Plugin Registration"" section:// src/server.js - Ensure this is registered in the Plugin Registration section fastify.register(require('@fastify/helmet'));
6. Testing
Writing automated tests is crucial for ensuring reliability. We'll use tap
, Fastify's default test runner.
1. Install tap
as a Dev Dependency:
npm install --save-dev tap
2. Update test
script in package.json
:
{
""name"": ""fastify-vonage-otp"",
""version"": ""1.0.0"",
""description"": """",
""main"": ""src/server.js"",
""scripts"": {
""start"": ""node src/server.js"",
""dev"": ""node src/server.js | pino-pretty"",
""test"": ""tap \""test/**/*.test.js\""""
},
""keywords"": [],
""author"": """",
""license"": ""ISC"",
""dependencies"": {
""@fastify/helmet"": ""^..."",
""@fastify/rate-limit"": ""^..."",
""@vonage/server-sdk"": ""^..."",
""dotenv"": ""^..."",
""fastify"": ""^...""
},
""devDependencies"": {
""pino-pretty"": ""^..."",
""tap"": ""^...""
}
}
(Note: Added @fastify/helmet
, @fastify/rate-limit
to dependencies and tap
, pino-pretty
to devDependencies based on previous steps. Ensure versions match your installation.)
3. Create Test File Structure:
mkdir test
mkdir test/routes
touch test/routes/otp.test.js
4. Write Basic Tests:
Here's an example testing the success path for /request-otp
, mocking the Vonage API call. You would typically create a test helper (test/helper.js
) to build the Fastify app instance for tests, potentially injecting mocks.
// test/routes/otp.test.js
'use strict'
const { test } = require('tap')
// Assume a helper exists at ../helper.js that builds the app instance
// const { build } = require('../helper')
// Placeholder for building the app - replace with your actual helper
async function build(t, mockVonage) {
// This function should build your Fastify app similar to server.js
// but allow injecting a mock Vonage client and disable the real listener.
// It's complex to show fully without the actual helper code.
// For now, we'll just return a mock app object for structure.
const fastify = require('fastify')();
// Mock the decorator if mockVonage is provided
if (mockVonage) {
fastify.decorate('vonage', mockVonage);
} else {
// Provide a default mock if none is passed, to avoid errors
fastify.decorate('vonage', { verify: { start: async () => {}, check: async () => {} } });
}
// Register routes, error handlers etc. as in server.js
const otpRoutes = require('../../src/routes/otp');
fastify.register(otpRoutes, { prefix: '/api/v1' });
// Add setErrorHandler from server.js here too
await fastify.ready(); // Ensure plugins are loaded
t.teardown(() => fastify.close()); // Close server after test
return fastify;
}
test('POST /api/v1/request-otp - success', async (t) => {
// Mock the Vonage verify start function
const mockVonage = {
verify: {
start: async (options) => {
t.equal(options.number, '+12345678900', 'should pass correct phone number');
t.ok(options.brand, 'should pass a brand name');
return { status: '0', request_id: 'mock-request-id-123' };
}
}
};
// Build the app with the mocked Vonage client
const app = await build(t, mockVonage);
const res = await app.inject({
method: 'POST',
url: '/api/v1/request-otp',
payload: {
phoneNumber: '+12345678900'
}
});
t.equal(res.statusCode, 200, 'returns a status code of 200');
const payload = JSON.parse(res.payload);
t.ok(payload.success, 'success field should be true');
t.equal(payload.requestId, 'mock-request-id-123', 'returns the correct request ID');
});
test('POST /api/v1/request-otp - validation failure (missing phone)', async (t) => {
// Build app without specific mock (or default mock from helper)
const app = await build(t);
const res = await app.inject({
method: 'POST',
url: '/api/v1/request-otp',
payload: { /* Missing phoneNumber */ }
});
t.equal(res.statusCode, 400, 'returns a status code of 400 for validation error');
const payload = JSON.parse(res.payload);
t.notOk(payload.success, 'success field should be false');
// Note: Exact error message structure depends on Fastify version and schema options
t.ok(payload.message.includes('body should have required property \'phoneNumber\''), 'error message indicates missing field');
});
// --- Add more tests ---
// - Test /request-otp Vonage error case (e.g., status != '0')
// - Test /verify-otp success case (mocking vonage.verify.check)
// - Test /verify-otp incorrect code case (mock status '6')
// - Test /verify-otp already verified case (mock status '16')
// - Test /verify-otp expired/not found case (mock status '101')
// - Test rate limiting (might require specific tap setup for timing/mocking rate limiter state)
5. Create Test Helper (Conceptual):
A file like test/helper.js
would typically contain a function to build and configure the Fastify app instance for testing, allowing injection of mocks and handling teardown. Creating a full helper is beyond the scope of this rewrite, but the test file structure assumes its existence.
Run tests using npm test
. Add comprehensive tests covering success paths, error paths (Vonage errors, validation errors), and security features like rate limiting.