@flink-app/otp-auth-plugin 0.12.1-alpha.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +879 -0
- package/dist/OtpAuthPlugin.d.ts +64 -0
- package/dist/OtpAuthPlugin.js +231 -0
- package/dist/OtpAuthPluginContext.d.ts +10 -0
- package/dist/OtpAuthPluginContext.js +2 -0
- package/dist/OtpAuthPluginOptions.d.ts +112 -0
- package/dist/OtpAuthPluginOptions.js +2 -0
- package/dist/OtpInternalContext.d.ts +11 -0
- package/dist/OtpInternalContext.js +2 -0
- package/dist/functions/initiate.d.ts +18 -0
- package/dist/functions/initiate.js +104 -0
- package/dist/functions/verify.d.ts +20 -0
- package/dist/functions/verify.js +142 -0
- package/dist/handlers/PostOtpInitiate.d.ts +7 -0
- package/dist/handlers/PostOtpInitiate.js +70 -0
- package/dist/handlers/PostOtpVerify.d.ts +7 -0
- package/dist/handlers/PostOtpVerify.js +86 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +24 -0
- package/dist/repos/OtpSessionRepo.d.ts +13 -0
- package/dist/repos/OtpSessionRepo.js +145 -0
- package/dist/schemas/InitiateRequest.d.ts +8 -0
- package/dist/schemas/InitiateRequest.js +2 -0
- package/dist/schemas/InitiateResponse.d.ts +8 -0
- package/dist/schemas/InitiateResponse.js +2 -0
- package/dist/schemas/OtpSession.d.ts +25 -0
- package/dist/schemas/OtpSession.js +2 -0
- package/dist/schemas/VerifyRequest.d.ts +6 -0
- package/dist/schemas/VerifyRequest.js +2 -0
- package/dist/schemas/VerifyResponse.d.ts +12 -0
- package/dist/schemas/VerifyResponse.js +2 -0
- package/dist/utils/otp-utils.d.ts +43 -0
- package/dist/utils/otp-utils.js +95 -0
- package/examples/basic-usage.ts +145 -0
- package/package.json +37 -0
- package/spec/OtpAuthPlugin.spec.ts +159 -0
- package/spec/OtpSessionRepo.spec.ts +194 -0
- package/spec/otp-utils.spec.ts +172 -0
- package/spec/support/jasmine.json +7 -0
- package/src/OtpAuthPlugin.ts +163 -0
- package/src/OtpAuthPluginContext.ts +11 -0
- package/src/OtpAuthPluginOptions.ts +135 -0
- package/src/OtpInternalContext.ts +12 -0
- package/src/functions/initiate.ts +86 -0
- package/src/functions/verify.ts +123 -0
- package/src/handlers/PostOtpInitiate.ts +28 -0
- package/src/handlers/PostOtpVerify.ts +42 -0
- package/src/index.ts +17 -0
- package/src/repos/OtpSessionRepo.ts +47 -0
- package/src/schemas/InitiateRequest.ts +8 -0
- package/src/schemas/InitiateResponse.ts +8 -0
- package/src/schemas/OtpSession.ts +25 -0
- package/src/schemas/VerifyRequest.ts +6 -0
- package/src/schemas/VerifyResponse.ts +12 -0
- package/src/utils/otp-utils.ts +89 -0
- package/tsconfig.dist.json +4 -0
- package/tsconfig.json +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
# OTP Auth Plugin
|
|
2
|
+
|
|
3
|
+
A flexible OTP (One-Time Password) authentication plugin for Flink that supports both SMS and email delivery methods with MongoDB session storage, JWT token generation, and configurable security settings.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- OTP code generation with configurable length (4-8 digits)
|
|
8
|
+
- Support for both SMS and email delivery
|
|
9
|
+
- Configurable code expiration (TTL)
|
|
10
|
+
- Rate limiting with maximum verification attempts
|
|
11
|
+
- Session locking after too many failed attempts
|
|
12
|
+
- MongoDB session storage with automatic TTL cleanup
|
|
13
|
+
- Built-in HTTP endpoints for OTP flow
|
|
14
|
+
- TypeScript support with full type safety
|
|
15
|
+
- Cryptographically secure code generation
|
|
16
|
+
- Identifier validation and normalization
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @flink-app/otp-auth-plugin @flink-app/jwt-auth-plugin
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Prerequisites
|
|
25
|
+
|
|
26
|
+
### 1. JWT Auth Plugin Dependency
|
|
27
|
+
|
|
28
|
+
This plugin requires `@flink-app/jwt-auth-plugin` to be installed and configured. The OTP Auth Plugin uses the JWT Auth Plugin to generate authentication tokens after successful verification.
|
|
29
|
+
|
|
30
|
+
### 2. SMS/Email Delivery
|
|
31
|
+
|
|
32
|
+
You need an SMS or email delivery mechanism. This can be another Flink plugin or external service:
|
|
33
|
+
|
|
34
|
+
- **SMS**: Use `@flink-app/sms-plugin` or services like Twilio, AWS SNS
|
|
35
|
+
- **Email**: Use `@flink-app/email-plugin` or services like SendGrid, AWS SES
|
|
36
|
+
|
|
37
|
+
### 3. MongoDB Connection
|
|
38
|
+
|
|
39
|
+
The plugin requires MongoDB to store OTP sessions.
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { FlinkApp } from "@flink-app/flink";
|
|
45
|
+
import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
|
|
46
|
+
import { otpAuthPlugin } from "@flink-app/otp-auth-plugin";
|
|
47
|
+
import { smsPlugin } from "@flink-app/sms-plugin";
|
|
48
|
+
import { emailPlugin } from "@flink-app/email-plugin";
|
|
49
|
+
import { Context } from "./Context";
|
|
50
|
+
|
|
51
|
+
const app = new FlinkApp<Context>({
|
|
52
|
+
name: "My App",
|
|
53
|
+
|
|
54
|
+
// JWT Auth Plugin MUST be configured first
|
|
55
|
+
auth: jwtAuthPlugin({
|
|
56
|
+
secret: process.env.JWT_SECRET!,
|
|
57
|
+
getUser: async (tokenData) => {
|
|
58
|
+
return await app.ctx.repos.userRepo.getById(tokenData.userId);
|
|
59
|
+
},
|
|
60
|
+
rolePermissions: {
|
|
61
|
+
user: ["read", "write"],
|
|
62
|
+
admin: ["read", "write", "delete"],
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
|
|
66
|
+
db: {
|
|
67
|
+
uri: process.env.MONGODB_URI!,
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
plugins: [
|
|
71
|
+
// SMS and email plugins for delivery
|
|
72
|
+
smsPlugin({
|
|
73
|
+
provider: "twilio",
|
|
74
|
+
accountSid: process.env.TWILIO_ACCOUNT_SID!,
|
|
75
|
+
authToken: process.env.TWILIO_AUTH_TOKEN!,
|
|
76
|
+
fromNumber: process.env.TWILIO_FROM_NUMBER!,
|
|
77
|
+
}),
|
|
78
|
+
emailPlugin({
|
|
79
|
+
provider: "sendgrid",
|
|
80
|
+
apiKey: process.env.SENDGRID_API_KEY!,
|
|
81
|
+
fromEmail: "noreply@myapp.com",
|
|
82
|
+
}),
|
|
83
|
+
|
|
84
|
+
// OTP Auth Plugin
|
|
85
|
+
otpAuthPlugin({
|
|
86
|
+
codeLength: 6, // 6-digit code
|
|
87
|
+
codeTTL: 300, // 5 minutes
|
|
88
|
+
maxAttempts: 3, // Lock after 3 failed attempts
|
|
89
|
+
|
|
90
|
+
// Callback to send the OTP code
|
|
91
|
+
onSendCode: async (code, identifier, method) => {
|
|
92
|
+
if (method === "sms") {
|
|
93
|
+
await app.ctx.plugins.sms.send({
|
|
94
|
+
to: identifier,
|
|
95
|
+
body: `Your verification code is: ${code}. Valid for 5 minutes.`,
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
await app.ctx.plugins.email.send({
|
|
99
|
+
to: identifier,
|
|
100
|
+
subject: "Your Verification Code",
|
|
101
|
+
text: `Your verification code is: ${code}. Valid for 5 minutes.`,
|
|
102
|
+
html: `<p>Your verification code is: <strong>${code}</strong></p><p>Valid for 5 minutes.</p>`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// Callback to retrieve user by identifier
|
|
109
|
+
onGetUser: async (identifier, method) => {
|
|
110
|
+
if (method === "sms") {
|
|
111
|
+
return await app.ctx.repos.userRepo.findOne({ phoneNumber: identifier });
|
|
112
|
+
} else {
|
|
113
|
+
return await app.ctx.repos.userRepo.findOne({ email: identifier });
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
// Callback after successful verification
|
|
118
|
+
onVerifySuccess: async (user, identifier, method) => {
|
|
119
|
+
const token = await app.ctx.auth.createToken({ userId: user._id, email: user.email }, user.roles);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
user: {
|
|
123
|
+
id: user._id,
|
|
124
|
+
email: user.email,
|
|
125
|
+
phoneNumber: user.phoneNumber,
|
|
126
|
+
name: user.name,
|
|
127
|
+
},
|
|
128
|
+
token,
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await app.start();
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Configuration
|
|
139
|
+
|
|
140
|
+
### OtpAuthPluginOptions
|
|
141
|
+
|
|
142
|
+
| Option | Type | Required | Default | Description |
|
|
143
|
+
| --------------------------- | ---------- | -------- | ---------------- | --------------------------------------------- |
|
|
144
|
+
| `codeLength` | `number` | No | `6` | Number of digits in OTP code (4-8) |
|
|
145
|
+
| `codeTTL` | `number` | No | `300` | Code validity in seconds (default: 5 minutes) |
|
|
146
|
+
| `maxAttempts` | `number` | No | `3` | Max verification attempts before lock |
|
|
147
|
+
| `onSendCode` | `Function` | Yes | - | Callback to send OTP code |
|
|
148
|
+
| `onGetUser` | `Function` | Yes | - | Callback to retrieve user by identifier |
|
|
149
|
+
| `onVerifySuccess` | `Function` | Yes | - | Callback after successful verification |
|
|
150
|
+
| `keepSessionsSec` | `number` | No | `86400` | Session retention in seconds (24 hours) |
|
|
151
|
+
| `otpSessionsCollectionName` | `string` | No | `"otp_sessions"` | MongoDB collection name |
|
|
152
|
+
| `registerRoutes` | `boolean` | No | `true` | Register built-in HTTP endpoints |
|
|
153
|
+
|
|
154
|
+
### Callback Functions
|
|
155
|
+
|
|
156
|
+
#### onSendCode
|
|
157
|
+
|
|
158
|
+
Send the OTP code to the user via SMS or email.
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
onSendCode: async (
|
|
162
|
+
code: string,
|
|
163
|
+
identifier: string,
|
|
164
|
+
method: "sms" | "email",
|
|
165
|
+
payload?: Record<string, any>
|
|
166
|
+
) => Promise<boolean>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Parameters:**
|
|
170
|
+
|
|
171
|
+
- `code` - The generated OTP code (e.g., "123456")
|
|
172
|
+
- `identifier` - User identifier (phone number or email)
|
|
173
|
+
- `method` - Delivery method ("sms" or "email")
|
|
174
|
+
- `payload` - Optional custom data from initiation
|
|
175
|
+
|
|
176
|
+
**Returns:** `true` if sent successfully, `false` otherwise
|
|
177
|
+
|
|
178
|
+
**Example:**
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
onSendCode: async (code, identifier, method, payload) => {
|
|
182
|
+
if (method === "sms") {
|
|
183
|
+
await ctx.plugins.sms.send({
|
|
184
|
+
to: identifier,
|
|
185
|
+
body: `Your code: ${code}`,
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
await ctx.plugins.email.send({
|
|
189
|
+
to: identifier,
|
|
190
|
+
subject: "Your verification code",
|
|
191
|
+
text: `Your code is: ${code}`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
};
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
#### onGetUser
|
|
199
|
+
|
|
200
|
+
Retrieve user by their identifier (phone number or email).
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
onGetUser: async (
|
|
204
|
+
identifier: string,
|
|
205
|
+
method: "sms" | "email",
|
|
206
|
+
payload?: Record<string, any>
|
|
207
|
+
) => Promise<any | null>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Parameters:**
|
|
211
|
+
|
|
212
|
+
- `identifier` - User identifier (normalized phone/email)
|
|
213
|
+
- `method` - Delivery method ("sms" or "email")
|
|
214
|
+
- `payload` - Optional custom data from initiation
|
|
215
|
+
|
|
216
|
+
**Returns:** User object or `null` if not found
|
|
217
|
+
|
|
218
|
+
**Example:**
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
onGetUser: async (identifier, method) => {
|
|
222
|
+
if (method === "sms") {
|
|
223
|
+
return await ctx.repos.userRepo.findOne({ phoneNumber: identifier });
|
|
224
|
+
} else {
|
|
225
|
+
return await ctx.repos.userRepo.findOne({ email: identifier });
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
#### onVerifySuccess
|
|
231
|
+
|
|
232
|
+
Generate JWT token and return user data after successful verification.
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
onVerifySuccess: async (
|
|
236
|
+
user: any,
|
|
237
|
+
identifier: string,
|
|
238
|
+
method: "sms" | "email",
|
|
239
|
+
payload?: Record<string, any>
|
|
240
|
+
) => Promise<{
|
|
241
|
+
user: any;
|
|
242
|
+
token: string;
|
|
243
|
+
}>
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Parameters:**
|
|
247
|
+
|
|
248
|
+
- `user` - User object from `onGetUser`
|
|
249
|
+
- `identifier` - User identifier
|
|
250
|
+
- `method` - Delivery method
|
|
251
|
+
- `payload` - Optional custom data from initiation
|
|
252
|
+
|
|
253
|
+
**Returns:** Object with user and JWT token
|
|
254
|
+
|
|
255
|
+
**Example:**
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
onVerifySuccess: async (user, identifier, method, payload) => {
|
|
259
|
+
// Generate JWT token
|
|
260
|
+
const token = await ctx.auth.createToken({ userId: user._id, email: user.email }, user.roles);
|
|
261
|
+
|
|
262
|
+
// Update last login time
|
|
263
|
+
await ctx.repos.userRepo.updateOne(user._id, {
|
|
264
|
+
lastLoginAt: new Date(),
|
|
265
|
+
lastLoginMethod: method,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
user: {
|
|
270
|
+
id: user._id,
|
|
271
|
+
email: user.email,
|
|
272
|
+
name: user.name,
|
|
273
|
+
},
|
|
274
|
+
token,
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## OTP Authentication Flow
|
|
280
|
+
|
|
281
|
+
### Complete Flow
|
|
282
|
+
|
|
283
|
+
1. User enters phone number or email
|
|
284
|
+
2. Client calls `/otp/initiate` with identifier and method
|
|
285
|
+
3. Plugin generates OTP code and creates session
|
|
286
|
+
4. Plugin calls `onSendCode` to deliver code
|
|
287
|
+
5. User receives code via SMS or email
|
|
288
|
+
6. User enters code in client
|
|
289
|
+
7. Client calls `/otp/verify` with session ID and code
|
|
290
|
+
8. Plugin validates code and checks attempts/expiration
|
|
291
|
+
9. Plugin calls `onGetUser` to fetch user
|
|
292
|
+
10. Plugin calls `onVerifySuccess` to generate JWT token
|
|
293
|
+
11. Plugin returns user and token to client
|
|
294
|
+
12. Client stores JWT token for authenticated requests
|
|
295
|
+
|
|
296
|
+
### Initiate OTP
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
POST /otp/initiate
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**Request Body:**
|
|
303
|
+
|
|
304
|
+
```json
|
|
305
|
+
{
|
|
306
|
+
"identifier": "+46701234567",
|
|
307
|
+
"method": "sms",
|
|
308
|
+
"payload": {
|
|
309
|
+
"returnUrl": "/dashboard"
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
**Response:**
|
|
315
|
+
|
|
316
|
+
```json
|
|
317
|
+
{
|
|
318
|
+
"data": {
|
|
319
|
+
"sessionId": "a1b2c3d4e5f6...",
|
|
320
|
+
"expiresAt": "2025-01-02T12:35:00.000Z",
|
|
321
|
+
"ttl": 300
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Verify OTP
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
POST /otp/verify
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**Request Body:**
|
|
333
|
+
|
|
334
|
+
```json
|
|
335
|
+
{
|
|
336
|
+
"sessionId": "a1b2c3d4e5f6...",
|
|
337
|
+
"code": "123456"
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**Response (Success - 200):**
|
|
342
|
+
|
|
343
|
+
```json
|
|
344
|
+
{
|
|
345
|
+
"data": {
|
|
346
|
+
"status": "success",
|
|
347
|
+
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
348
|
+
"user": {
|
|
349
|
+
"id": "507f1f77bcf86cd799439011",
|
|
350
|
+
"email": "user@example.com",
|
|
351
|
+
"name": "John Doe"
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
**Response (Invalid Code - 401):**
|
|
358
|
+
|
|
359
|
+
```json
|
|
360
|
+
{
|
|
361
|
+
"data": {
|
|
362
|
+
"status": "invalid_code",
|
|
363
|
+
"message": "Invalid verification code",
|
|
364
|
+
"remainingAttempts": 2
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
**Response (Locked - 403):**
|
|
370
|
+
|
|
371
|
+
```json
|
|
372
|
+
{
|
|
373
|
+
"data": {
|
|
374
|
+
"status": "locked",
|
|
375
|
+
"message": "Too many failed attempts. Session is locked.",
|
|
376
|
+
"remainingAttempts": 0
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
**Response (Expired - 403):**
|
|
382
|
+
|
|
383
|
+
```json
|
|
384
|
+
{
|
|
385
|
+
"data": {
|
|
386
|
+
"status": "expired",
|
|
387
|
+
"message": "Verification code has expired"
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
**Response (Not Found - 404):**
|
|
393
|
+
|
|
394
|
+
```json
|
|
395
|
+
{
|
|
396
|
+
"data": {
|
|
397
|
+
"status": "not_found",
|
|
398
|
+
"message": "Session not found or expired"
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Context API
|
|
404
|
+
|
|
405
|
+
The plugin exposes methods via `ctx.plugins.otpAuth`:
|
|
406
|
+
|
|
407
|
+
### initiate
|
|
408
|
+
|
|
409
|
+
Programmatically initiate OTP authentication.
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
const result = await ctx.plugins.otpAuth.initiate({
|
|
413
|
+
identifier: "+46701234567",
|
|
414
|
+
method: "sms",
|
|
415
|
+
payload: { customData: "value" },
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Returns: { sessionId, expiresAt, ttl }
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### verify
|
|
422
|
+
|
|
423
|
+
Programmatically verify OTP code.
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
const result = await ctx.plugins.otpAuth.verify({
|
|
427
|
+
sessionId: "a1b2c3d4e5f6...",
|
|
428
|
+
code: "123456",
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Returns: { status, token?, user?, remainingAttempts?, message? }
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
## Identifier Normalization
|
|
435
|
+
|
|
436
|
+
The plugin automatically normalizes identifiers for consistency:
|
|
437
|
+
|
|
438
|
+
### Phone Numbers
|
|
439
|
+
|
|
440
|
+
- Removes spaces, dashes, parentheses: `+46 (70) 123-4567` → `+46701234567`
|
|
441
|
+
- Preserves `+` prefix for country codes
|
|
442
|
+
- Accepts various formats: `+1 (555) 123-4567`, `555-123-4567`, `5551234567`
|
|
443
|
+
|
|
444
|
+
### Email Addresses
|
|
445
|
+
|
|
446
|
+
- Converts to lowercase: `User@Example.COM` → `user@example.com`
|
|
447
|
+
- Trims whitespace
|
|
448
|
+
- Basic validation: must contain `@` and domain
|
|
449
|
+
|
|
450
|
+
## Security Best Practices
|
|
451
|
+
|
|
452
|
+
### 1. Code Length and TTL
|
|
453
|
+
|
|
454
|
+
Balance security and usability:
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
otpAuthPlugin({
|
|
458
|
+
codeLength: 6, // 6 digits = 1 million combinations
|
|
459
|
+
codeTTL: 300, // 5 minutes - short enough to prevent reuse
|
|
460
|
+
maxAttempts: 3, // Lock after 3 tries
|
|
461
|
+
});
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
**Recommendations:**
|
|
465
|
+
|
|
466
|
+
- **4 digits**: Only for low-security scenarios (10,000 combinations)
|
|
467
|
+
- **6 digits**: Good balance for most use cases (1,000,000 combinations)
|
|
468
|
+
- **8 digits**: High security (100,000,000 combinations)
|
|
469
|
+
|
|
470
|
+
### 2. Rate Limiting
|
|
471
|
+
|
|
472
|
+
Implement rate limiting on initiation to prevent SMS/email abuse:
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
import rateLimit from "express-rate-limit";
|
|
476
|
+
|
|
477
|
+
app.use(
|
|
478
|
+
"/otp/initiate",
|
|
479
|
+
rateLimit({
|
|
480
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
481
|
+
max: 3, // 3 requests per window
|
|
482
|
+
message: "Too many OTP requests. Please try again later.",
|
|
483
|
+
})
|
|
484
|
+
);
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
### 3. User Verification
|
|
488
|
+
|
|
489
|
+
Always verify user exists before sending codes:
|
|
490
|
+
|
|
491
|
+
```typescript
|
|
492
|
+
onSendCode: async (code, identifier, method) => {
|
|
493
|
+
// Check if user exists first
|
|
494
|
+
const user = await ctx.repos.userRepo.findOne({
|
|
495
|
+
[method === "sms" ? "phoneNumber" : "email"]: identifier,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
if (!user) {
|
|
499
|
+
// Don't reveal user doesn't exist - silently fail
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Send code only if user exists
|
|
504
|
+
await sendCode(code, identifier, method);
|
|
505
|
+
return true;
|
|
506
|
+
};
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
### 4. HTTPS Only
|
|
510
|
+
|
|
511
|
+
Always use HTTPS in production to prevent code interception.
|
|
512
|
+
|
|
513
|
+
### 5. Secrets Management
|
|
514
|
+
|
|
515
|
+
Never commit secrets to version control:
|
|
516
|
+
|
|
517
|
+
```bash
|
|
518
|
+
# .env
|
|
519
|
+
JWT_SECRET=your_jwt_secret
|
|
520
|
+
TWILIO_ACCOUNT_SID=...
|
|
521
|
+
TWILIO_AUTH_TOKEN=...
|
|
522
|
+
SENDGRID_API_KEY=...
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
### 6. Session Cleanup
|
|
526
|
+
|
|
527
|
+
The plugin automatically cleans up old sessions using MongoDB TTL indexes. Configure retention based on compliance needs:
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
otpAuthPlugin({
|
|
531
|
+
keepSessionsSec: 86400, // 24 hours (default)
|
|
532
|
+
// keepSessionsSec: 3600, // 1 hour for stricter compliance
|
|
533
|
+
// keepSessionsSec: 604800, // 7 days for audit trails
|
|
534
|
+
});
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### 7. Audit Logging
|
|
538
|
+
|
|
539
|
+
Log authentication attempts for security monitoring:
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
onVerifySuccess: async (user, identifier, method) => {
|
|
543
|
+
await ctx.repos.auditLogRepo.create({
|
|
544
|
+
userId: user._id,
|
|
545
|
+
action: "otp_login",
|
|
546
|
+
method,
|
|
547
|
+
identifier,
|
|
548
|
+
timestamp: new Date(),
|
|
549
|
+
ip: req.ip,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const token = await ctx.auth.createToken({ userId: user._id }, user.roles);
|
|
553
|
+
return { user, token };
|
|
554
|
+
};
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
## Client Integration Examples
|
|
558
|
+
|
|
559
|
+
### React Web App
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
import React, { useState } from "react";
|
|
563
|
+
|
|
564
|
+
function OtpLogin() {
|
|
565
|
+
const [phone, setPhone] = useState("");
|
|
566
|
+
const [sessionId, setSessionId] = useState("");
|
|
567
|
+
const [code, setCode] = useState("");
|
|
568
|
+
const [step, setStep] = useState<"enter-phone" | "enter-code">("enter-phone");
|
|
569
|
+
|
|
570
|
+
const handleInitiate = async () => {
|
|
571
|
+
const response = await fetch("/otp/initiate", {
|
|
572
|
+
method: "POST",
|
|
573
|
+
headers: { "Content-Type": "application/json" },
|
|
574
|
+
body: JSON.stringify({
|
|
575
|
+
identifier: phone,
|
|
576
|
+
method: "sms",
|
|
577
|
+
}),
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const { data } = await response.json();
|
|
581
|
+
setSessionId(data.sessionId);
|
|
582
|
+
setStep("enter-code");
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const handleVerify = async () => {
|
|
586
|
+
const response = await fetch("/otp/verify", {
|
|
587
|
+
method: "POST",
|
|
588
|
+
headers: { "Content-Type": "application/json" },
|
|
589
|
+
body: JSON.stringify({
|
|
590
|
+
sessionId,
|
|
591
|
+
code,
|
|
592
|
+
}),
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const { data } = await response.json();
|
|
596
|
+
|
|
597
|
+
if (data.status === "success") {
|
|
598
|
+
// Store token and redirect
|
|
599
|
+
localStorage.setItem("jwt_token", data.token);
|
|
600
|
+
window.location.href = "/dashboard";
|
|
601
|
+
} else {
|
|
602
|
+
alert(data.message || "Verification failed");
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
return (
|
|
607
|
+
<div>
|
|
608
|
+
{step === "enter-phone" && (
|
|
609
|
+
<div>
|
|
610
|
+
<h1>Login with SMS</h1>
|
|
611
|
+
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Phone number" />
|
|
612
|
+
<button onClick={handleInitiate}>Send Code</button>
|
|
613
|
+
</div>
|
|
614
|
+
)}
|
|
615
|
+
|
|
616
|
+
{step === "enter-code" && (
|
|
617
|
+
<div>
|
|
618
|
+
<h1>Enter Verification Code</h1>
|
|
619
|
+
<input type="text" value={code} onChange={(e) => setCode(e.target.value)} placeholder="6-digit code" maxLength={6} />
|
|
620
|
+
<button onClick={handleVerify}>Verify</button>
|
|
621
|
+
</div>
|
|
622
|
+
)}
|
|
623
|
+
</div>
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### React Native App
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
import React, { useState } from "react";
|
|
632
|
+
import { View, TextInput, Button, Alert } from "react-native";
|
|
633
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
634
|
+
|
|
635
|
+
function OtpLogin() {
|
|
636
|
+
const [phone, setPhone] = useState("");
|
|
637
|
+
const [sessionId, setSessionId] = useState("");
|
|
638
|
+
const [code, setCode] = useState("");
|
|
639
|
+
const [step, setStep] = useState<"enter-phone" | "enter-code">("enter-phone");
|
|
640
|
+
|
|
641
|
+
const handleInitiate = async () => {
|
|
642
|
+
const response = await fetch("https://api.myapp.com/otp/initiate", {
|
|
643
|
+
method: "POST",
|
|
644
|
+
headers: { "Content-Type": "application/json" },
|
|
645
|
+
body: JSON.stringify({
|
|
646
|
+
identifier: phone,
|
|
647
|
+
method: "sms",
|
|
648
|
+
}),
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
const { data } = await response.json();
|
|
652
|
+
setSessionId(data.sessionId);
|
|
653
|
+
setStep("enter-code");
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const handleVerify = async () => {
|
|
657
|
+
const response = await fetch("https://api.myapp.com/otp/verify", {
|
|
658
|
+
method: "POST",
|
|
659
|
+
headers: { "Content-Type": "application/json" },
|
|
660
|
+
body: JSON.stringify({
|
|
661
|
+
sessionId,
|
|
662
|
+
code,
|
|
663
|
+
}),
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
const { data } = await response.json();
|
|
667
|
+
|
|
668
|
+
if (data.status === "success") {
|
|
669
|
+
await AsyncStorage.setItem("jwt_token", data.token);
|
|
670
|
+
// Navigate to home screen
|
|
671
|
+
} else {
|
|
672
|
+
Alert.alert("Error", data.message || "Verification failed");
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
return (
|
|
677
|
+
<View>
|
|
678
|
+
{step === "enter-phone" && (
|
|
679
|
+
<>
|
|
680
|
+
<TextInput value={phone} onChangeText={setPhone} placeholder="Phone number" keyboardType="phone-pad" />
|
|
681
|
+
<Button title="Send Code" onPress={handleInitiate} />
|
|
682
|
+
</>
|
|
683
|
+
)}
|
|
684
|
+
|
|
685
|
+
{step === "enter-code" && (
|
|
686
|
+
<>
|
|
687
|
+
<TextInput value={code} onChangeText={setCode} placeholder="6-digit code" keyboardType="number-pad" maxLength={6} />
|
|
688
|
+
<Button title="Verify" onPress={handleVerify} />
|
|
689
|
+
</>
|
|
690
|
+
)}
|
|
691
|
+
</View>
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
## Custom Payload Usage
|
|
697
|
+
|
|
698
|
+
Pass custom data through the OTP flow:
|
|
699
|
+
|
|
700
|
+
```typescript
|
|
701
|
+
// Initiate with payload
|
|
702
|
+
const { data } = await fetch("/otp/initiate", {
|
|
703
|
+
method: "POST",
|
|
704
|
+
body: JSON.stringify({
|
|
705
|
+
identifier: "user@example.com",
|
|
706
|
+
method: "email",
|
|
707
|
+
payload: {
|
|
708
|
+
action: "password-reset",
|
|
709
|
+
returnUrl: "/new-password",
|
|
710
|
+
},
|
|
711
|
+
}),
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// Access payload in callbacks
|
|
715
|
+
onVerifySuccess: async (user, identifier, method, payload) => {
|
|
716
|
+
if (payload?.action === "password-reset") {
|
|
717
|
+
// Handle password reset flow
|
|
718
|
+
return {
|
|
719
|
+
user,
|
|
720
|
+
token: await ctx.auth.createToken({ userId: user._id }, ["password-reset"]),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Regular login flow
|
|
725
|
+
return {
|
|
726
|
+
user,
|
|
727
|
+
token: await ctx.auth.createToken({ userId: user._id }, user.roles),
|
|
728
|
+
};
|
|
729
|
+
};
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
## Error Handling
|
|
733
|
+
|
|
734
|
+
Handle errors gracefully in your callbacks:
|
|
735
|
+
|
|
736
|
+
```typescript
|
|
737
|
+
onSendCode: async (code, identifier, method) => {
|
|
738
|
+
try {
|
|
739
|
+
if (method === "sms") {
|
|
740
|
+
await ctx.plugins.sms.send({
|
|
741
|
+
to: identifier,
|
|
742
|
+
body: `Your code: ${code}`,
|
|
743
|
+
});
|
|
744
|
+
} else {
|
|
745
|
+
await ctx.plugins.email.send({
|
|
746
|
+
to: identifier,
|
|
747
|
+
subject: "Verification code",
|
|
748
|
+
text: `Your code: ${code}`,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return true;
|
|
752
|
+
} catch (error) {
|
|
753
|
+
// Log error but don't expose details
|
|
754
|
+
console.error("Failed to send OTP:", error);
|
|
755
|
+
return false; // Return false to indicate failure
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
## TypeScript Types
|
|
761
|
+
|
|
762
|
+
```typescript
|
|
763
|
+
import {
|
|
764
|
+
OtpAuthPluginOptions,
|
|
765
|
+
OtpSession,
|
|
766
|
+
InitiateRequest,
|
|
767
|
+
InitiateResponse,
|
|
768
|
+
VerifyRequest,
|
|
769
|
+
VerifyResponse,
|
|
770
|
+
OtpAuthPluginContext,
|
|
771
|
+
} from "@flink-app/otp-auth-plugin";
|
|
772
|
+
|
|
773
|
+
// OTP Session
|
|
774
|
+
interface OtpSession {
|
|
775
|
+
_id?: string;
|
|
776
|
+
sessionId: string;
|
|
777
|
+
identifier: string;
|
|
778
|
+
method: "sms" | "email";
|
|
779
|
+
code: string;
|
|
780
|
+
attempts: number;
|
|
781
|
+
maxAttempts: number;
|
|
782
|
+
status: "pending" | "verified" | "expired" | "locked";
|
|
783
|
+
createdAt: Date;
|
|
784
|
+
expiresAt: Date;
|
|
785
|
+
verifiedAt?: Date;
|
|
786
|
+
payload?: Record<string, any>;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Initiate request/response
|
|
790
|
+
interface InitiateRequest {
|
|
791
|
+
identifier: string;
|
|
792
|
+
method: "sms" | "email";
|
|
793
|
+
payload?: Record<string, any>;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
interface InitiateResponse {
|
|
797
|
+
sessionId: string;
|
|
798
|
+
expiresAt: Date;
|
|
799
|
+
ttl: number;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Verify request/response
|
|
803
|
+
interface VerifyRequest {
|
|
804
|
+
sessionId: string;
|
|
805
|
+
code: string;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
interface VerifyResponse {
|
|
809
|
+
status: "success" | "invalid_code" | "expired" | "locked" | "not_found";
|
|
810
|
+
token?: string;
|
|
811
|
+
user?: any;
|
|
812
|
+
remainingAttempts?: number;
|
|
813
|
+
message?: string;
|
|
814
|
+
}
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
## Troubleshooting
|
|
818
|
+
|
|
819
|
+
### Code Not Received
|
|
820
|
+
|
|
821
|
+
**Issue:** User doesn't receive OTP code
|
|
822
|
+
|
|
823
|
+
**Solution:**
|
|
824
|
+
|
|
825
|
+
- Check `onSendCode` callback logs for errors
|
|
826
|
+
- Verify SMS/email service credentials
|
|
827
|
+
- Check identifier format (phone/email validation)
|
|
828
|
+
- Ensure user exists in database (if checking in `onSendCode`)
|
|
829
|
+
|
|
830
|
+
### Session Not Found
|
|
831
|
+
|
|
832
|
+
**Issue:** Verify returns "Session not found"
|
|
833
|
+
|
|
834
|
+
**Solution:**
|
|
835
|
+
|
|
836
|
+
- Session may have expired (check `codeTTL`)
|
|
837
|
+
- Verify `sessionId` is passed correctly from initiate response
|
|
838
|
+
- Check MongoDB connection and collection name
|
|
839
|
+
|
|
840
|
+
### Too Many Attempts
|
|
841
|
+
|
|
842
|
+
**Issue:** Session locked after failed verifications
|
|
843
|
+
|
|
844
|
+
**Solution:**
|
|
845
|
+
|
|
846
|
+
- User must request a new code via `/otp/initiate`
|
|
847
|
+
- Consider implementing admin unlock functionality
|
|
848
|
+
- Adjust `maxAttempts` if too restrictive
|
|
849
|
+
|
|
850
|
+
### Invalid Identifier
|
|
851
|
+
|
|
852
|
+
**Issue:** Initiate fails with "Invalid phone number/email format"
|
|
853
|
+
|
|
854
|
+
**Solution:**
|
|
855
|
+
|
|
856
|
+
- Ensure phone numbers include country code: `+46701234567`
|
|
857
|
+
- Validate email format: `user@example.com`
|
|
858
|
+
- Check identifier normalization in logs
|
|
859
|
+
|
|
860
|
+
## Production Checklist
|
|
861
|
+
|
|
862
|
+
- [ ] Configure HTTPS for all endpoints
|
|
863
|
+
- [ ] Set secure JWT secret in environment variables
|
|
864
|
+
- [ ] Configure SMS/email service with production credentials
|
|
865
|
+
- [ ] Implement rate limiting on `/otp/initiate`
|
|
866
|
+
- [ ] Set appropriate `codeTTL` (recommend 5 minutes)
|
|
867
|
+
- [ ] Set appropriate `maxAttempts` (recommend 3)
|
|
868
|
+
- [ ] Set appropriate `codeLength` (recommend 6)
|
|
869
|
+
- [ ] Implement audit logging for OTP attempts
|
|
870
|
+
- [ ] Set up monitoring for failed verifications
|
|
871
|
+
- [ ] Test OTP flow for both SMS and email
|
|
872
|
+
- [ ] Configure session retention (`keepSessionsSec`)
|
|
873
|
+
- [ ] Implement user existence check in `onSendCode`
|
|
874
|
+
- [ ] Set up alerts for high failure rates
|
|
875
|
+
- [ ] Document OTP flow for support team
|
|
876
|
+
|
|
877
|
+
## License
|
|
878
|
+
|
|
879
|
+
MIT
|