This guide provides a comprehensive, step-by-step walkthrough for building a secure SMS-based Two-Factor Authentication (2FA) system using Sinch's Verification API within a Fastify Node.js application. We will cover everything from project setup and core logic implementation to error handling, security considerations, and deployment.
Target Audience: Developers familiar with Node.js and potentially Fastify, looking to add robust SMS OTP verification to their applications. Goal: Build a production-ready Fastify API that handles user registration, login, and secures sessions using Sinch SMS OTP verification. Outcome: A secure, documented, and testable API backend with SMS 2FA.
What You Will Build:
- A Fastify API backend using TypeScript.
- User registration and basic password-based login.
- Integration with Sinch Verification API for sending and verifying SMS OTP codes.
- Secure session management (e.g., using JWT) enhanced with 2FA status.
- API endpoints for initiating and verifying OTPs.
Why This Approach?
- Fastify: A high-performance, low-overhead Node.js web framework focused on developer experience and speed.
- Sinch Verification API: A dedicated service for handling the complexities of OTP generation, delivery (SMS, voice, etc.), and verification, simplifying implementation and improving reliability. Using a dedicated service offloads concerns like number formatting, carrier deliverability, and OTP lifecycle management.
- TypeScript: Provides static typing for better code maintainability, scalability, and reduced runtime errors.
- Database (Prisma): Simplifies database interactions with type safety and migrations (using PostgreSQL in this example).
System Architecture:
+-------------+ +-----------------+ +-----------------+ +-----------------+
| End User | <---> | Frontend App | <--> | Fastify Backend | <--> | Sinch Platform |
| (Browser/ | | (React/Vue/etc.)| | (Node.js/TS) | | (Verification |
| Mobile App) | +-----------------+ +-------+---------+ | API via SDK) |
+-------------+ | +-----------------+
|
+-------+---------+
| Database |
| (PostgreSQL/ |
| Prisma) |
+-----------------+
- User Action: User initiates login or a sensitive action requiring 2FA.
- Frontend Request: Frontend sends credentials (and potentially phone number) to the Fastify backend.
- Backend Logic (Login): Backend verifies username/password against the database.
- Backend Logic (OTP Initiate): If credentials are valid (or upon registration confirmation), backend calls the Sinch SDK to initiate SMS verification for the user's phone number.
- Sinch Sends SMS: Sinch generates an OTP and sends it via SMS to the user's phone.
- User Enters OTP: User receives the SMS and enters the OTP into the frontend.
- Frontend Request (Verify): Frontend sends the entered OTP to the Fastify backend.
- Backend Logic (OTP Verify): Backend calls the Sinch SDK to report (verify) the entered OTP against the phone number.
- Sinch Verifies: Sinch checks if the OTP is correct for that number and initiation request.
- Backend Response: Backend receives success/failure from Sinch. If successful, it updates the user's session/state to indicate 2FA completion and grants access. If failed, it returns an error.
- Frontend Update: Frontend reflects the success or failure to the user.
Prerequisites:
- Node.js (v18 or later recommended) and npm/yarn installed.
- A Sinch Account: Sign up at Sinch.com and create a Verification App.
- A mobile phone number capable of receiving SMS messages for testing.
- Basic understanding of TypeScript, REST APIs, and
async/await
. - Docker and Docker Compose (optional, for running a local PostgreSQL database).
- A code editor (e.g., VS Code).
curl
or a tool like Postman/Insomnia for API testing.
1. Setting Up the Project
Let's initialize our Fastify project using TypeScript and set up the basic structure.
1.1 Initialize Project & Install Dependencies
Open your terminal and run the following commands:
# Create project directory and navigate into it
mkdir fastify-sinch-otp
cd fastify-sinch-otp
# Initialize Node.js project
npm init -y
# Install Fastify and core dependencies
npm install fastify @fastify/sensible
# Install TypeScript and related development dependencies
npm install --save-dev typescript @types/node ts-node nodemon @tsconfig/node18
# Install Prisma (ORM) and PostgreSQL driver
npm install @prisma/client
npm install --save-dev prisma pg # pg is the Node.js driver for PostgreSQL
# Install Sinch Verification SDK
npm install @sinch/verification
# Install libraries for environment variables and validation
npm install dotenv fastify-type-provider-zod zod
# Install libraries for JWT authentication and password hashing
npm install @fastify/jwt bcrypt
npm install --save-dev @types/bcrypt @types/jsonwebtoken # @types/jsonwebtoken might be implicitly handled by @fastify/jwt, but explicit install is safe.
1.2 Configure TypeScript (tsconfig.json
)
Create a tsconfig.json
file in the project root:
// tsconfig.json
{
"extends": "@tsconfig/node18/tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "CommonJS",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"sourceMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]
}
Why these settings?
extends
: Inherits sensible defaults for Node.js 18.outDir
,rootDir
: Define project structure for source and compiled files.module
:CommonJS
is standard for Node.js unless you're specifically setting up ES Modules.strict
: Catches more potential errors during development.esModuleInterop
,forceConsistentCasingInFileNames
,skipLibCheck
: Common settings for compatibility and faster builds.sourceMap
: Crucial for debugging compiled code.
1.3 Configure Development Scripts (package.json
)
Add the following scripts to your package.json
:
// package.json (add or modify the "scripts" section)
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts",
"test": "vitest run", // Example using Vitest; replace with your test runner command (e.g., "jest")
"prisma:dev:deploy": "prisma migrate deploy",
"prisma:dev:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:studio": "prisma studio"
},
build
: Compiles TypeScript to JavaScript.start
: Runs the compiled JavaScript application (for production).dev
: Runs the application usingts-node
andnodemon
for automatic restarts during development.test
: Command to run automated tests (example uses Vitest).prisma:*
: Commands for managing database migrations and generating the Prisma client.
1.4 Project Structure
Create the following directory structure:
fastify-sinch-otp/
prisma/
schema.prisma
# Prisma schema definitionmigrations/
# Database migration files (generated)
src/
modules/
# Feature modules (e.g., auth, users)auth/
auth.controller.ts
auth.routes.ts
auth.schema.ts
# Zod schemas & type definitionsauth.service.ts
plugins/
# Fastify plugins (e.g., db client, auth)prisma.ts
sinch.ts
jwtAuth.ts
config/
# Configuration files/logicindex.ts
types/
# Global or shared type definitionsfastify-jwt.d.ts
# JWT type augmentations
utils/
# Utility functionshash.ts
app.ts
# Main Fastify application setupserver.ts
# Entry point, starts the server
.env
# Environment variables (ignored by git).env.example
# Example environment variables.gitignore
package.json
package-lock.json
tsconfig.json
Why this structure?
- Modularity: Grouping features (like
auth
) into modules makes the codebase easier to navigate and maintain. - Separation of Concerns: Routes handle HTTP requests/responses, controllers orchestrate, services contain business logic, schemas define data shapes, plugins encapsulate reusable setup (like DB connection).
- Configuration: Centralizing configuration makes it easier to manage settings across different environments.
- Types: Dedicated
types
directory for global type definitions and augmentations (like for Fastify plugins).
1.5 Environment Variables (.env
and .env.example
)
Create .env.example
with placeholders:
# .env.example
# Application Configuration
NODE_ENV=development
PORT=3000
HOST=0.0.0.0 # Listen on all available network interfaces
# Database Configuration (PostgreSQL example)
DATABASE_URL="postgresql://user:password@localhost:5432/mydatabase?schema=public"
# Sinch Configuration
# Get these from your Sinch Dashboard -> Verification -> Your App
SINCH_APPLICATION_KEY=YOUR_SINCH_APP_KEY
SINCH_APPLICATION_SECRET=YOUR_SINCH_APP_SECRET
# JWT Configuration
JWT_SECRET=a_very_strong_and_long_secret_key_please_change_me # Use a secure random string
JWT_EXPIRES_IN=1h # How long a standard login token lasts
JWT_OTP_EXPIRES_IN=5m # How long a temporary pre-OTP token lasts
Create a .env
file (copy from .env.example
) and fill in your actual development values. Crucially, add .env
to your .gitignore
file to prevent committing secrets.
# .gitignore
node_modules
dist
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*
Why .env
? Keeps sensitive information (API keys, database URLs, secrets) out of your codebase, making it secure and configurable per environment.
1.6 Docker Setup for PostgreSQL (Optional)
If you don't have PostgreSQL running locally, create a docker-compose.yml
file:
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15
container_name: fastify_sinch_db
restart: always
ports:
- "5432:5432" # Expose port 5432 locally
environment:
POSTGRES_USER: user # Match your .env DATABASE_URL
POSTGRES_PASSWORD: password # Match your .env DATABASE_URL
POSTGRES_DB: mydatabase # Match your .env DATABASE_URL
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Run docker-compose up -d
to start the database container in the background.
2. Database Schema and User Model (Prisma)
We'll use Prisma to define our database schema and interact with the database.
2.1 Define Prisma Schema
Edit prisma/schema.prisma
:
// prisma/schema.prisma
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""postgresql""
url = env(""DATABASE_URL"") // Loads from .env file
}
model User {
id String @id @default(cuid()) // Unique identifier
email String @unique // User's email, must be unique
password String // Hashed password
name String? // Optional user name
phone String? @unique // User's phone number, unique if present
// Make required if needed for your flow
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// You could add more models here (e.g., Session, AuditLog) if needed
Why this schema?
id
: Standard unique primary key.email
,password
: Essential for basic authentication.phone
: Needed for Sinch SMS OTP. Making it@unique
prevents multiple accounts using the same phone number for verification. Decide if it should be nullable or required based on your signup flow.createdAt
,updatedAt
: Standard practice for tracking record changes.
2.2 Generate Prisma Client and Run Initial Migration
- Generate Client: Create the type-safe Prisma client based on your schema.
npx prisma generate
- Create Migration: Create the initial SQL migration file based on the schema changes. Give it a descriptive name.
This command will:
npx prisma migrate dev --name init
- Create the
prisma/migrations
directory if it doesn't exist. - Generate a new SQL migration file inside a timestamped folder (e.g.,
prisma/migrations/20250420000000_init/migration.sql
). - Apply the migration to your database (creating the
User
table). - Ensure the Prisma client is up-to-date.
- Create the
2.3 Create Prisma Plugin for Fastify
Create src/plugins/prisma.ts
to make the Prisma client available throughout your Fastify application.
// src/plugins/prisma.ts
import fp from 'fastify-plugin';
import { PrismaClient } from '@prisma/client';
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient;
}
}
export default fp(async (fastify) => {
const prisma = new PrismaClient();
await prisma.$connect(); // Connect to the database when the plugin is loaded
// Make Prisma Client available through the fastify instance: fastify.prisma
fastify.decorate('prisma', prisma);
// Add a hook to disconnect Prisma Client when the server shuts down
fastify.addHook('onClose', async (instance) => {
await instance.prisma.$disconnect();
});
});
Why this plugin?
- Encapsulates Prisma client initialization.
- Uses
fastify-plugin
to avoid encapsulation issues and makefastify.prisma
available globally within the Fastify instance. - Handles connecting and disconnecting gracefully.
3. Implementing Core Functionality (Auth Service & Controller)
Now, let's build the core logic for user registration, login, and preparing for OTP.
3.1 Configuration Setup
Create src/config/index.ts
to load environment variables safely.
// src/config/index.ts
import dotenv from 'dotenv';
import { z } from 'zod';
dotenv.config(); // Load .env file
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
HOST: z.string().default('0.0.0.0'),
DATABASE_URL: z.string().url(),
SINCH_APPLICATION_KEY: z.string().min(1, "Sinch Application Key is required"),
SINCH_APPLICATION_SECRET: z.string().min(1, "Sinch Application Secret is required"),
JWT_SECRET: z.string().min(32, "JWT Secret must be at least 32 characters long"),
JWT_EXPIRES_IN: z.string().default('1h'),
JWT_OTP_EXPIRES_IN: z.string().default('5m'),
});
// Validate environment variables at startup
try {
const config = envSchema.parse(process.env);
console.log("_ Environment variables loaded successfully.");
// Make config available globally (or export selectively)
// Option 1: Simple export
// export default config;
// Option 2: More structured export (if needed)
const appConfig = {
env: config.NODE_ENV,
port: config.PORT,
host: config.HOST,
databaseUrl: config.DATABASE_URL,
sinch: {
appKey: config.SINCH_APPLICATION_KEY,
appSecret: config.SINCH_APPLICATION_SECRET,
},
jwt: {
secret: config.JWT_SECRET,
expiresIn: config.JWT_EXPIRES_IN,
otpExpiresIn: config.JWT_OTP_EXPIRES_IN,
}
};
console.log("__ App Config:", { // Log non-sensitive parts
env: appConfig.env,
port: appConfig.port,
host: appConfig.host,
jwt: { expiresIn: appConfig.jwt.expiresIn, otpExpiresIn: appConfig.jwt.otpExpiresIn }
});
// Export the structured config
// (Remove sensitive details like full DB URL, secrets from logs in production)
if (appConfig.env !== 'development') {
console.log("__ Sinch App Key: [Loaded]"); // Avoid logging keys in prod
} else {
console.log(`__ Sinch App Key: ${appConfig.sinch.appKey.substring(0, 5)}...`);
}
export default appConfig;
} catch (error) {
if (error instanceof z.ZodError) {
console.error("_ Invalid environment variables:", error.format());
} else {
console.error("_ Error loading environment variables:", error);
}
process.exit(1); // Exit if config is invalid
}
Why Zod for config? Ensures required variables are present and have the correct type at application startup, preventing runtime errors later.
3.2 Zod Schemas and Type Definitions
Create src/modules/auth/auth.schema.ts
to define input shapes using Zod and centralize JWT payload types.
// src/modules/auth/auth.schema.ts
import { z } from 'zod';
// --- Input Schemas ---
// Schema for user registration input
export const registerUserSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
password: z.string().min(8, { message: "Password must be at least 8 characters long" }),
name: z.string().optional(),
phone: z.string().min(10, { message: "Phone number seems too short" }) // Basic validation, consider more specific regex
.max(15, { message: "Phone number seems too long"})
.regex(/^\+?[1-9]\d{1,14}$/, { message: "Invalid phone number format (E.164 expected, e.g., +14155552671)"}), // E.164 format recommended by Sinch/Twilio
});
export type RegisterUserInput = z.infer<typeof registerUserSchema>;
// Schema for user login input
export const loginUserSchema = z.object({
email: z.string().email(),
password: z.string(),
});
export type LoginUserInput = z.infer<typeof loginUserSchema>;
// Schema for initiating OTP
export const initiateOtpSchema = z.object({
// Typically, the phone number comes from the logged-in user's profile (via JWT)
// If allowing arbitrary numbers, add validation here:
// phone: z.string().min(10).max(15).regex(/^\+?[1-9]\d{1,14}$/),
});
export type InitiateOtpInput = z.infer<typeof initiateOtpSchema>; // May not be needed if using user context
// Schema for verifying OTP
export const verifyOtpSchema = z.object({
otp: z.string().length(4, { message: "OTP must be 4 digits" }) // Adjust length based on Sinch config (default is often 4-6)
.regex(/^\d+$/, { message: "OTP must contain only digits" }),
});
export type VerifyOtpInput = z.infer<typeof verifyOtpSchema>;
// --- Response Schemas (Examples) ---
export const authResponseSchema = z.object({
accessToken: z.string()
});
export const userResponseSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string().nullable(),
phone: z.string().nullable(),
createdAt: z.date(),
});
export const loginResponseSchema = z.object({
otpToken: z.string().optional(),
accessToken: z.string().optional(),
message: z.string().optional()
});
export const messageResponseSchema = z.object({
message: z.string()
});
// --- JWT Payload Types ---
export interface AuthJWTPayload {
id: string;
email: string;
otpVerified: boolean; // Flag to track OTP status
}
export interface OtpJWTPayload {
id: string;
phone: string; // Include phone needed for OTP verification
}
Why Zod schemas? Provides runtime validation of request bodies and parameters, ensuring data integrity before it hits your service logic. Also enables type inference for request handlers. Centralizing JWT types improves consistency.
3.3 Password Hashing Utility
Create src/utils/hash.ts
:
// src/utils/hash.ts
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 10; // Adjust cost factor as needed (higher is slower but more secure)
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function comparePassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}
Why bcrypt? The standard and secure way to hash passwords. Never store plain text passwords.
3.4 Auth Service Logic
Create src/modules/auth/auth.service.ts
. This handles the core business logic, interacting with the database and hashing.
// src/modules/auth/auth.service.ts
import { PrismaClient, User } from '@prisma/client';
import { hashPassword, comparePassword } from '../../utils/hash';
import { RegisterUserInput, LoginUserInput } from './auth.schema';
import { BadRequest, NotFound, Unauthorized } from 'http-errors'; // Use http-errors for standard HTTP errors
export class AuthService {
constructor(private prisma: PrismaClient) {} // Inject Prisma client
async registerUser(input: RegisterUserInput): Promise<Omit<User_ 'password'>> {
const { email, password, name, phone } = input;
// Check if user or phone already exists
const existingUser = await this.prisma.user.findFirst({
where: { OR: [{ email }, { phone }] }, // Check uniqueness for both email and phone
});
if (existingUser) {
if (existingUser.email === email) {
throw new BadRequest('User with this email already exists.');
}
if (existingUser.phone === phone) {
throw new BadRequest('User with this phone number already exists.');
}
}
const hashedPassword = await hashPassword(password);
const user = await this.prisma.user.create({
data: {
email,
password: hashedPassword,
name,
phone,
},
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password: _, ...userWithoutPassword } = user; // Exclude password from return
return userWithoutPassword;
}
async loginUser(input: LoginUserInput): Promise<User> {
const { email, password } = input;
const user = await this.prisma.user.findUnique({
where: { email },
});
if (!user) {
throw new NotFound('User not found.'); // More specific than Unauthorized before checking password
}
const isPasswordValid = await comparePassword(password, user.password);
if (!isPasswordValid) {
throw new Unauthorized('Invalid password.');
}
// User is found and password is valid
return user;
}
// Find user by ID (useful for getting user details after JWT validation)
async findUserById(userId: string): Promise<Omit<User_ 'password'> | null> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { // Explicitly select fields to exclude password
id: true,
email: true,
name: true,
phone: true,
createdAt: true,
updatedAt: true,
}
});
if (!user) {
throw new NotFound('User not found.');
}
return user;
}
}
Why this structure?
- Dependency Injection: Injecting
PrismaClient
makes the service testable (you can pass a mock client). - Clear Methods: Each method has a single responsibility (register, login, find).
- Error Handling: Uses
http-errors
for standard, descriptive HTTP errors. Throws errors instead of returning complex objects. - Security: Explicitly removes the password hash before returning user data. Handles existing user checks.
3.5 Auth Controller (Request/Response Handling)
Create src/modules/auth/auth.controller.ts
. This connects HTTP requests to the service logic.
// src/modules/auth/auth.controller.ts
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import { AuthService } from './auth.service';
import {
RegisterUserInput,
LoginUserInput,
VerifyOtpInput,
AuthJWTPayload, // Import JWT types
OtpJWTPayload // Import JWT types
} from './auth.schema';
import config from '../../config'; // Import app config
import { BadRequest, Unauthorized } from 'http-errors'; // Import error types
export class AuthController {
constructor(
private authService: AuthService,
private fastify: FastifyInstance // Need fastify instance for JWT signing
) {}
async registerHandler(request: FastifyRequest<{ Body: RegisterUserInput }>, reply: FastifyReply) {
try {
const user = await this.authService.registerUser(request.body);
// Decide on registration flow:
// 1. Log in directly?
// 2. Require email verification?
// 3. Require OTP verification immediately?
// For this guide, we'll return the user data (excluding password)
// and require them to log in separately to start the OTP flow.
return reply.code(201).send(user);
} catch (error: any) {
request.log.error(error, "Registration failed");
// Let the global error handler (added later) handle http-errors
throw error;
}
}
async loginHandler(request: FastifyRequest<{ Body: LoginUserInput }>, reply: FastifyReply) {
try {
const user = await this.authService.loginUser(request.body);
if (!user.phone) {
// Handle cases where user might not have a phone number registered yet
// Option: return an error, prompt user to add phone, or skip OTP
request.log.warn(`User ${user.email} logged in but has no phone number for OTP.`);
// For now, let's generate a standard token but they can't proceed to sensitive areas
const tokenPayload: AuthJWTPayload = { id: user.id, email: user.email, otpVerified: false };
const accessToken = this.fastify.jwt.sign(tokenPayload, { expiresIn: config.jwt.expiresIn });
return reply.send({ accessToken });
// Alternatively: throw new BadRequest("Phone number required for 2FA.");
}
// Credentials valid, but OTP not yet verified.
// Issue a short-lived "pre-OTP" token.
const otpTokenPayload: OtpJWTPayload = { id: user.id, phone: user.phone };
const otpToken = this.fastify.jwt.sign(otpTokenPayload, { expiresIn: config.jwt.otpExpiresIn });
// Send this short-lived token back. The client must use this
// token to call the /auth/otp/send endpoint.
return reply.send({ otpToken, message: "Credentials verified. Proceed to OTP verification." });
} catch (error: any) {
request.log.error(error, "Login failed");
throw error; // Let error handler manage response
}
}
// Handler to get current user details (requires standard JWT)
async getMeHandler(request: FastifyRequest, reply: FastifyReply) {
// Assumes JWT authentication middleware has run and populated request.user
const userId = (request.user as AuthJWTPayload).id; // Type assertion using imported type
try {
const user = await this.authService.findUserById(userId);
return reply.send(user);
} catch (error) {
request.log.error(error, `Failed to get user details for ID: ${userId}`);
throw error;
}
}
// --- OTP Handlers ---
// This endpoint requires the short-lived 'otpToken'
async initiateOtpHandler(request: FastifyRequest, reply: FastifyReply) {
// Assumes JWT ('otpToken') verification middleware has run
const otpPayload = request.user as OtpJWTPayload; // Contains id and phone, use imported type
try {
// NOTE: Assumes fastify.initiateSmsOtp is defined in a Sinch plugin (covered later)
const sinchResponse = await (this.fastify as any).initiateSmsOtp(otpPayload.phone);
// Sinch handles sending the SMS. We just need to confirm initiation.
// The 'id' in sinchResponse is Sinch's internal verification ID,
// we don't typically need to store or use it directly when using reportByIdentity.
request.log.info(`OTP initiated successfully via Sinch for user ${otpPayload.id}, phone ${otpPayload.phone}`);
// No need to send anything specific back, just confirm success.
// The user checks their phone for the SMS.
return reply.code(200).send({ message: `OTP sent successfully to ${otpPayload.phone.substring(0, otpPayload.phone.length - 4)}****.` });
} catch (error: any) {
request.log.error(error, `Failed to initiate OTP for user ${otpPayload.id}`);
// Handle potential Sinch errors (e.g., invalid number format, rate limits)
// The sinch plugin might throw, or we can handle specific Sinch error codes here
if (error.response?.data?.errorCode) {
// Example: Map Sinch error to user-friendly message
const sinchErrorCode = error.response.data.errorCode;
if (sinchErrorCode === 40003) { // Parameter validation failed (e.g., bad number format)
throw new BadRequest("Invalid phone number format provided to Sinch.");
}
// Add more mappings as needed based on Sinch docs / testing
}
throw new BadRequest("Failed to send OTP. Please try again later."); // Generic fallback
}
}
// This endpoint also requires the short-lived 'otpToken'
async verifyOtpHandler(request: FastifyRequest<{ Body: VerifyOtpInput }>, reply: FastifyReply) {
// Assumes JWT ('otpToken') verification middleware has run
const otpPayload = request.user as OtpJWTPayload; // Contains id and phone, use imported type
const { otp } = request.body;
try {
// NOTE: Assumes fastify.verifySmsOtp is defined in a Sinch plugin (covered later)
const sinchResponse = await (this.fastify as any).verifySmsOtp(otpPayload.phone, otp);
if (sinchResponse.status === 'SUCCESSFUL') {
request.log.info(`OTP verification successful for user ${otpPayload.id}`);
// OTP is correct! Issue the final, long-lived access token.
// We need the user's email for the final payload. Fetch it.
const user = await this.authService.findUserById(otpPayload.id);
if (!user) throw new Unauthorized("User associated with token not found during OTP verification."); // Should not happen if token is valid
const finalTokenPayload: AuthJWTPayload = {
id: user.id,
email: user.email,
otpVerified: true, // Mark as OTP verified
};
const accessToken = this.fastify.jwt.sign(finalTokenPayload, { expiresIn: config.jwt.expiresIn });
// Send back the final access token
return reply.send({ accessToken });
} else {
// Handle other Sinch statuses (e.g., 'FAIL', 'DENIED', 'ERROR')
request.log.warn(`OTP verification failed for user ${otpPayload.id}. Sinch status: ${sinchResponse.status}, Reason: ${sinchResponse.reason || 'N/A'}`);
throw new Unauthorized("Invalid or expired OTP.");
}
} catch (error: any) {
request.log.error(error, `Failed to verify OTP for user ${otpPayload.id}`);
if (error instanceof Unauthorized || error instanceof BadRequest) {
throw error; // Re-throw known HTTP errors
}
// Handle potential Sinch SDK errors or other unexpected issues
throw new BadRequest("Failed to verify OTP. Please try again later."); // Generic fallback
}
}
}