@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.normalizeIdentifier = exports.normalizeEmail = exports.normalizePhoneNumber = exports.validateIdentifier = exports.generateSessionId = exports.generateOtpCode = void 0;
|
|
7
|
+
var crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
/**
|
|
9
|
+
* Generates a random numeric OTP code.
|
|
10
|
+
*
|
|
11
|
+
* @param length - Number of digits (4-8)
|
|
12
|
+
* @returns A numeric string of the specified length
|
|
13
|
+
*/
|
|
14
|
+
function generateOtpCode(length) {
|
|
15
|
+
if (length === void 0) { length = 6; }
|
|
16
|
+
if (length < 4 || length > 8) {
|
|
17
|
+
throw new Error("OTP code length must be between 4 and 8 digits");
|
|
18
|
+
}
|
|
19
|
+
// Generate a random number with the specified number of digits
|
|
20
|
+
var min = Math.pow(10, length - 1);
|
|
21
|
+
var max = Math.pow(10, length) - 1;
|
|
22
|
+
// Use crypto.randomInt for cryptographically secure random numbers
|
|
23
|
+
return crypto_1.default.randomInt(min, max + 1).toString();
|
|
24
|
+
}
|
|
25
|
+
exports.generateOtpCode = generateOtpCode;
|
|
26
|
+
/**
|
|
27
|
+
* Generates a unique session ID.
|
|
28
|
+
*
|
|
29
|
+
* @returns A cryptographically secure random hex string
|
|
30
|
+
*/
|
|
31
|
+
function generateSessionId() {
|
|
32
|
+
return crypto_1.default.randomBytes(16).toString("hex");
|
|
33
|
+
}
|
|
34
|
+
exports.generateSessionId = generateSessionId;
|
|
35
|
+
/**
|
|
36
|
+
* Validates that an identifier looks like a valid phone number or email.
|
|
37
|
+
*
|
|
38
|
+
* @param identifier - The identifier to validate
|
|
39
|
+
* @param method - The expected delivery method
|
|
40
|
+
* @returns true if valid, false otherwise
|
|
41
|
+
*/
|
|
42
|
+
function validateIdentifier(identifier, method) {
|
|
43
|
+
if (!identifier || typeof identifier !== "string") {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (method === "email") {
|
|
47
|
+
// Basic email validation
|
|
48
|
+
var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
49
|
+
return emailRegex.test(identifier);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Phone number validation (allow various formats)
|
|
53
|
+
// This accepts: digits, spaces, dashes, parentheses, and + prefix
|
|
54
|
+
var phoneRegex = /^\+?[\d\s\-()]{8,20}$/;
|
|
55
|
+
return phoneRegex.test(identifier);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.validateIdentifier = validateIdentifier;
|
|
59
|
+
/**
|
|
60
|
+
* Normalizes a phone number by removing formatting characters.
|
|
61
|
+
*
|
|
62
|
+
* @param phoneNumber - The phone number to normalize
|
|
63
|
+
* @returns Normalized phone number (digits and + only)
|
|
64
|
+
*/
|
|
65
|
+
function normalizePhoneNumber(phoneNumber) {
|
|
66
|
+
// Remove all characters except digits and +
|
|
67
|
+
return phoneNumber.replace(/[^\d+]/g, "");
|
|
68
|
+
}
|
|
69
|
+
exports.normalizePhoneNumber = normalizePhoneNumber;
|
|
70
|
+
/**
|
|
71
|
+
* Normalizes an email by converting to lowercase and trimming.
|
|
72
|
+
*
|
|
73
|
+
* @param email - The email to normalize
|
|
74
|
+
* @returns Normalized email
|
|
75
|
+
*/
|
|
76
|
+
function normalizeEmail(email) {
|
|
77
|
+
return email.toLowerCase().trim();
|
|
78
|
+
}
|
|
79
|
+
exports.normalizeEmail = normalizeEmail;
|
|
80
|
+
/**
|
|
81
|
+
* Normalizes an identifier based on the delivery method.
|
|
82
|
+
*
|
|
83
|
+
* @param identifier - The identifier to normalize
|
|
84
|
+
* @param method - The delivery method
|
|
85
|
+
* @returns Normalized identifier
|
|
86
|
+
*/
|
|
87
|
+
function normalizeIdentifier(identifier, method) {
|
|
88
|
+
if (method === "email") {
|
|
89
|
+
return normalizeEmail(identifier);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
return normalizePhoneNumber(identifier);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
exports.normalizeIdentifier = normalizeIdentifier;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic OTP Auth Plugin Usage Example
|
|
3
|
+
*
|
|
4
|
+
* This example demonstrates how to set up the OTP Auth Plugin
|
|
5
|
+
* with both SMS and email support.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { FlinkApp } from "@flink-app/flink";
|
|
9
|
+
import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
|
|
10
|
+
import { otpAuthPlugin } from "@flink-app/otp-auth-plugin";
|
|
11
|
+
|
|
12
|
+
// Example: Define your context
|
|
13
|
+
interface Context {
|
|
14
|
+
// Your context definition
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function start() {
|
|
18
|
+
const app = new FlinkApp<Context>({
|
|
19
|
+
name: "OTP Auth Example",
|
|
20
|
+
|
|
21
|
+
// JWT Auth Plugin (required)
|
|
22
|
+
auth: jwtAuthPlugin({
|
|
23
|
+
secret: process.env.JWT_SECRET || "your-secret-key",
|
|
24
|
+
getUser: async (tokenData) => {
|
|
25
|
+
// Retrieve user from database
|
|
26
|
+
const user = await app.ctx.repos.userRepo.getById(tokenData.userId);
|
|
27
|
+
return {
|
|
28
|
+
id: user._id,
|
|
29
|
+
username: user.username,
|
|
30
|
+
roles: user.roles,
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
rolePermissions: {
|
|
34
|
+
user: ["read", "write"],
|
|
35
|
+
admin: ["read", "write", "delete"],
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
|
|
39
|
+
db: {
|
|
40
|
+
uri: process.env.MONGODB_URI || "mongodb://localhost:27017/otp-example",
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
plugins: [
|
|
44
|
+
// OTP Auth Plugin
|
|
45
|
+
otpAuthPlugin({
|
|
46
|
+
// Configuration
|
|
47
|
+
codeLength: 6, // 6-digit code
|
|
48
|
+
codeTTL: 300, // 5 minutes
|
|
49
|
+
maxAttempts: 3, // Lock after 3 failed attempts
|
|
50
|
+
|
|
51
|
+
// Callback to send OTP code
|
|
52
|
+
onSendCode: async (code, identifier, method, payload) => {
|
|
53
|
+
console.log(`[OTP] Sending code ${code} to ${identifier} via ${method}`);
|
|
54
|
+
|
|
55
|
+
if (method === "sms") {
|
|
56
|
+
// In production, use a real SMS service:
|
|
57
|
+
// await app.ctx.plugins.sms.send({
|
|
58
|
+
// to: identifier,
|
|
59
|
+
// body: `Your verification code is: ${code}`
|
|
60
|
+
// });
|
|
61
|
+
|
|
62
|
+
// For demo purposes, just log
|
|
63
|
+
console.log(`[SMS] To: ${identifier}, Code: ${code}`);
|
|
64
|
+
} else {
|
|
65
|
+
// In production, use a real email service:
|
|
66
|
+
// await app.ctx.plugins.email.send({
|
|
67
|
+
// to: identifier,
|
|
68
|
+
// subject: 'Your Verification Code',
|
|
69
|
+
// text: `Your verification code is: ${code}`
|
|
70
|
+
// });
|
|
71
|
+
|
|
72
|
+
// For demo purposes, just log
|
|
73
|
+
console.log(`[EMAIL] To: ${identifier}, Code: ${code}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return true;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// Callback to get user by identifier
|
|
80
|
+
onGetUser: async (identifier, method, payload) => {
|
|
81
|
+
console.log(`[OTP] Getting user by ${method}: ${identifier}`);
|
|
82
|
+
|
|
83
|
+
// Find user by phone or email
|
|
84
|
+
if (method === "sms") {
|
|
85
|
+
return await app.ctx.repos.userRepo.findOne({
|
|
86
|
+
phoneNumber: identifier,
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
return await app.ctx.repos.userRepo.findOne({
|
|
90
|
+
email: identifier,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// Callback after successful verification
|
|
96
|
+
onVerifySuccess: async (user, identifier, method, payload) => {
|
|
97
|
+
console.log(`[OTP] Verification successful for user: ${user._id}`);
|
|
98
|
+
|
|
99
|
+
// Generate JWT token
|
|
100
|
+
const token = await app.ctx.auth.createToken(
|
|
101
|
+
{
|
|
102
|
+
userId: user._id,
|
|
103
|
+
username: user.username,
|
|
104
|
+
},
|
|
105
|
+
user.roles
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Return user and token
|
|
109
|
+
return {
|
|
110
|
+
user: {
|
|
111
|
+
id: user._id,
|
|
112
|
+
username: user.username,
|
|
113
|
+
email: user.email,
|
|
114
|
+
phoneNumber: user.phoneNumber,
|
|
115
|
+
},
|
|
116
|
+
token,
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await app.start();
|
|
124
|
+
|
|
125
|
+
console.log("\n=== OTP Auth Plugin Example ===");
|
|
126
|
+
console.log("API Endpoints:");
|
|
127
|
+
console.log(" POST /otp/initiate - Initiate OTP authentication");
|
|
128
|
+
console.log(" POST /otp/verify - Verify OTP code");
|
|
129
|
+
console.log("\nExample requests:");
|
|
130
|
+
console.log('\n1. Initiate SMS OTP:');
|
|
131
|
+
console.log(' POST /otp/initiate');
|
|
132
|
+
console.log(' { "identifier": "+46701234567", "method": "sms" }');
|
|
133
|
+
console.log('\n2. Initiate Email OTP:');
|
|
134
|
+
console.log(' POST /otp/initiate');
|
|
135
|
+
console.log(' { "identifier": "user@example.com", "method": "email" }');
|
|
136
|
+
console.log('\n3. Verify OTP:');
|
|
137
|
+
console.log(' POST /otp/verify');
|
|
138
|
+
console.log(' { "sessionId": "...", "code": "123456" }');
|
|
139
|
+
console.log("\n================================\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
start().catch((error) => {
|
|
143
|
+
console.error("Failed to start application:", error);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flink-app/otp-auth-plugin",
|
|
3
|
+
"version": "0.12.1-alpha.40",
|
|
4
|
+
"description": "Flink plugin for OTP (One-Time Password) authentication via SMS or email",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --preserve-symlinks -r ts-node/register -- node_modules/jasmine/bin/jasmine --config=./spec/support/jasmine.json",
|
|
7
|
+
"test:watch": "nodemon --ext ts --exec 'jasmine-ts --config=./spec/support/jasmine.json'",
|
|
8
|
+
"prepare": "tsc --project tsconfig.dist.json",
|
|
9
|
+
"watch": "tsc-watch --project tsconfig.dist.json"
|
|
10
|
+
},
|
|
11
|
+
"author": "joel@frost.se",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"main": "dist/index.js",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"mongodb": "^6.15.0"
|
|
20
|
+
},
|
|
21
|
+
"overrides": {
|
|
22
|
+
"@types/node": "22.13.10"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@flink-app/flink": "^0.12.1-alpha.40",
|
|
26
|
+
"@types/jasmine": "^3.7.1",
|
|
27
|
+
"@types/node": "22.13.10",
|
|
28
|
+
"jasmine": "^3.7.0",
|
|
29
|
+
"jasmine-spec-reporter": "^7.0.0",
|
|
30
|
+
"mongodb": "6.15.0",
|
|
31
|
+
"nodemon": "^2.0.7",
|
|
32
|
+
"ts-node": "^9.1.1",
|
|
33
|
+
"tsc-watch": "^4.2.9",
|
|
34
|
+
"typescript": "5.4.5"
|
|
35
|
+
},
|
|
36
|
+
"gitHead": "456502f273fe9473df05b71a803f3eda1a2f8931"
|
|
37
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTP Auth Plugin Initialization Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for plugin configuration and initialization
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { otpAuthPlugin } from "../src/OtpAuthPlugin";
|
|
8
|
+
|
|
9
|
+
describe("OtpAuthPlugin", () => {
|
|
10
|
+
describe("Plugin Initialization", () => {
|
|
11
|
+
it("should throw error if onSendCode callback is missing", () => {
|
|
12
|
+
expect(() => {
|
|
13
|
+
otpAuthPlugin({
|
|
14
|
+
onGetUser: async () => null,
|
|
15
|
+
onVerifySuccess: async () => ({ user: {}, token: "" }),
|
|
16
|
+
} as any);
|
|
17
|
+
}).toThrowError(/onSendCode callback is required/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should throw error if onGetUser callback is missing", () => {
|
|
21
|
+
expect(() => {
|
|
22
|
+
otpAuthPlugin({
|
|
23
|
+
onSendCode: async () => true,
|
|
24
|
+
onVerifySuccess: async () => ({ user: {}, token: "" }),
|
|
25
|
+
} as any);
|
|
26
|
+
}).toThrowError(/onGetUser callback is required/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should throw error if onVerifySuccess callback is missing", () => {
|
|
30
|
+
expect(() => {
|
|
31
|
+
otpAuthPlugin({
|
|
32
|
+
onSendCode: async () => true,
|
|
33
|
+
onGetUser: async () => null,
|
|
34
|
+
} as any);
|
|
35
|
+
}).toThrowError(/onVerifySuccess callback is required/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should throw error if codeLength is less than 4", () => {
|
|
39
|
+
expect(() => {
|
|
40
|
+
otpAuthPlugin({
|
|
41
|
+
codeLength: 3,
|
|
42
|
+
onSendCode: async () => true,
|
|
43
|
+
onGetUser: async () => null,
|
|
44
|
+
onVerifySuccess: async () => ({ user: {}, token: "" }),
|
|
45
|
+
});
|
|
46
|
+
}).toThrowError(/codeLength must be between 4 and 8/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should throw error if codeLength is greater than 8", () => {
|
|
50
|
+
expect(() => {
|
|
51
|
+
otpAuthPlugin({
|
|
52
|
+
codeLength: 9,
|
|
53
|
+
onSendCode: async () => true,
|
|
54
|
+
onGetUser: async () => null,
|
|
55
|
+
onVerifySuccess: async () => ({ user: {}, token: "" }),
|
|
56
|
+
});
|
|
57
|
+
}).toThrowError(/codeLength must be between 4 and 8/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should throw error if maxAttempts is invalid", () => {
|
|
61
|
+
// Note: maxAttempts validation happens after default is applied
|
|
62
|
+
// So 0 becomes the default 3, but we can test > 10
|
|
63
|
+
expect(() => {
|
|
64
|
+
otpAuthPlugin({
|
|
65
|
+
maxAttempts: 11,
|
|
66
|
+
onSendCode: async () => true,
|
|
67
|
+
onGetUser: async () => null,
|
|
68
|
+
onVerifySuccess: async () => ({ user: {}, token: "" }),
|
|
69
|
+
});
|
|
70
|
+
}).toThrowError(/maxAttempts must be between 1 and 10/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
it("should create plugin with valid configuration", () => {
|
|
75
|
+
const plugin = otpAuthPlugin({
|
|
76
|
+
codeLength: 6,
|
|
77
|
+
codeTTL: 300,
|
|
78
|
+
maxAttempts: 3,
|
|
79
|
+
onSendCode: async (code, identifier, method) => {
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
onGetUser: async (identifier, method) => {
|
|
83
|
+
return null;
|
|
84
|
+
},
|
|
85
|
+
onVerifySuccess: async (user, identifier, method) => {
|
|
86
|
+
return { user: {}, token: "" };
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(plugin).toBeDefined();
|
|
91
|
+
expect(plugin.id).toBe("otpAuth");
|
|
92
|
+
expect(plugin.db).toBeDefined();
|
|
93
|
+
expect(plugin.db?.useHostDb).toBe(true);
|
|
94
|
+
expect(plugin.ctx).toBeDefined();
|
|
95
|
+
expect(plugin.init).toBeDefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should use default values when optional config is omitted", () => {
|
|
99
|
+
const plugin = otpAuthPlugin({
|
|
100
|
+
onSendCode: async () => true,
|
|
101
|
+
onGetUser: async () => null,
|
|
102
|
+
onVerifySuccess: async () => ({ user: {}, token: "" }),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(plugin).toBeDefined();
|
|
106
|
+
// Options are frozen with original values, defaults are applied internally
|
|
107
|
+
expect(plugin.ctx.options.codeLength).toBeUndefined();
|
|
108
|
+
expect(plugin.ctx.options.codeTTL).toBeUndefined();
|
|
109
|
+
expect(plugin.ctx.options.maxAttempts).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should accept custom configuration values", () => {
|
|
113
|
+
const plugin = otpAuthPlugin({
|
|
114
|
+
codeLength: 4,
|
|
115
|
+
codeTTL: 600,
|
|
116
|
+
maxAttempts: 5,
|
|
117
|
+
keepSessionsSec: 3600,
|
|
118
|
+
otpSessionsCollectionName: "custom_otp",
|
|
119
|
+
registerRoutes: false,
|
|
120
|
+
onSendCode: async () => true,
|
|
121
|
+
onGetUser: async () => null,
|
|
122
|
+
onVerifySuccess: async () => ({ user: {}, token: "" }),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(plugin.ctx.options.codeLength).toBe(4);
|
|
126
|
+
expect(plugin.ctx.options.codeTTL).toBe(600);
|
|
127
|
+
expect(plugin.ctx.options.maxAttempts).toBe(5);
|
|
128
|
+
expect(plugin.ctx.options.keepSessionsSec).toBe(3600);
|
|
129
|
+
expect(plugin.ctx.options.otpSessionsCollectionName).toBe("custom_otp");
|
|
130
|
+
expect(plugin.ctx.options.registerRoutes).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should provide context API methods", () => {
|
|
134
|
+
const plugin = otpAuthPlugin({
|
|
135
|
+
onSendCode: async () => true,
|
|
136
|
+
onGetUser: async () => null,
|
|
137
|
+
onVerifySuccess: async () => ({ user: {}, token: "" }),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(plugin.ctx.initiate).toBeDefined();
|
|
141
|
+
expect(typeof plugin.ctx.initiate).toBe("function");
|
|
142
|
+
expect(plugin.ctx.verify).toBeDefined();
|
|
143
|
+
expect(typeof plugin.ctx.verify).toBe("function");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should freeze options object to prevent modification", () => {
|
|
147
|
+
const plugin = otpAuthPlugin({
|
|
148
|
+
codeLength: 6,
|
|
149
|
+
onSendCode: async () => true,
|
|
150
|
+
onGetUser: async () => null,
|
|
151
|
+
onVerifySuccess: async () => ({ user: {}, token: "" }),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(() => {
|
|
155
|
+
(plugin.ctx.options as any).codeLength = 4;
|
|
156
|
+
}).toThrow();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { MongoClient, Db } from "mongodb";
|
|
2
|
+
import OtpSessionRepo from "../src/repos/OtpSessionRepo";
|
|
3
|
+
import OtpSession from "../src/schemas/OtpSession";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* OTP Session Repository Tests
|
|
7
|
+
*
|
|
8
|
+
* Tests critical repository methods with actual MongoDB connection.
|
|
9
|
+
*/
|
|
10
|
+
describe("OtpSessionRepo", () => {
|
|
11
|
+
let client: MongoClient;
|
|
12
|
+
let db: Db;
|
|
13
|
+
let sessionRepo: OtpSessionRepo;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
// Connect to test database
|
|
17
|
+
const mongoUrl = process.env.TEST_MONGO_URL || "mongodb://localhost:27017";
|
|
18
|
+
client = new MongoClient(mongoUrl);
|
|
19
|
+
await client.connect();
|
|
20
|
+
db = client.db("otp_auth_plugin_test");
|
|
21
|
+
|
|
22
|
+
// Initialize repository
|
|
23
|
+
sessionRepo = new OtpSessionRepo("otp_sessions", db);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(async () => {
|
|
27
|
+
// Clean up and close connection
|
|
28
|
+
await db.dropDatabase();
|
|
29
|
+
await client.close();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
// Clear collection between tests
|
|
34
|
+
await db.collection("otp_sessions").deleteMany({});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should create and retrieve session by sessionId", async () => {
|
|
38
|
+
// Arrange
|
|
39
|
+
const session: Omit<OtpSession, "_id"> = {
|
|
40
|
+
sessionId: "test-session-123",
|
|
41
|
+
identifier: "+46701234567",
|
|
42
|
+
method: "sms",
|
|
43
|
+
code: "123456",
|
|
44
|
+
attempts: 0,
|
|
45
|
+
maxAttempts: 3,
|
|
46
|
+
status: "pending",
|
|
47
|
+
createdAt: new Date(),
|
|
48
|
+
expiresAt: new Date(Date.now() + 300000), // 5 minutes
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Act
|
|
52
|
+
const created = await sessionRepo.createSession(session);
|
|
53
|
+
const found = await sessionRepo.getSession("test-session-123");
|
|
54
|
+
|
|
55
|
+
// Assert
|
|
56
|
+
expect(created._id).toBeDefined();
|
|
57
|
+
expect(found).toBeDefined();
|
|
58
|
+
expect(found?.sessionId).toBe("test-session-123");
|
|
59
|
+
expect(found?.identifier).toBe("+46701234567");
|
|
60
|
+
expect(found?.method).toBe("sms");
|
|
61
|
+
expect(found?.code).toBe("123456");
|
|
62
|
+
expect(found?.status).toBe("pending");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should increment attempts counter", async () => {
|
|
66
|
+
// Arrange
|
|
67
|
+
const session: Omit<OtpSession, "_id"> = {
|
|
68
|
+
sessionId: "increment-test",
|
|
69
|
+
identifier: "user@example.com",
|
|
70
|
+
method: "email",
|
|
71
|
+
code: "654321",
|
|
72
|
+
attempts: 0,
|
|
73
|
+
maxAttempts: 3,
|
|
74
|
+
status: "pending",
|
|
75
|
+
createdAt: new Date(),
|
|
76
|
+
expiresAt: new Date(Date.now() + 300000),
|
|
77
|
+
};
|
|
78
|
+
await sessionRepo.createSession(session);
|
|
79
|
+
|
|
80
|
+
// Act
|
|
81
|
+
const updated = await sessionRepo.incrementAttempts("increment-test");
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
expect(updated).toBeDefined();
|
|
85
|
+
expect(updated?.attempts).toBe(1);
|
|
86
|
+
expect(updated?.status).toBe("pending");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should lock session after max attempts reached", async () => {
|
|
90
|
+
// Arrange
|
|
91
|
+
const session: Omit<OtpSession, "_id"> = {
|
|
92
|
+
sessionId: "lock-test",
|
|
93
|
+
identifier: "+46701234567",
|
|
94
|
+
method: "sms",
|
|
95
|
+
code: "111111",
|
|
96
|
+
attempts: 2,
|
|
97
|
+
maxAttempts: 3,
|
|
98
|
+
status: "pending",
|
|
99
|
+
createdAt: new Date(),
|
|
100
|
+
expiresAt: new Date(Date.now() + 300000),
|
|
101
|
+
};
|
|
102
|
+
await sessionRepo.createSession(session);
|
|
103
|
+
|
|
104
|
+
// Act - increment to reach max attempts
|
|
105
|
+
const updated = await sessionRepo.incrementAttempts("lock-test");
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
108
|
+
expect(updated?.attempts).toBe(3);
|
|
109
|
+
expect(updated?.status).toBe("locked");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should mark session as verified", async () => {
|
|
113
|
+
// Arrange
|
|
114
|
+
const session: Omit<OtpSession, "_id"> = {
|
|
115
|
+
sessionId: "verify-test",
|
|
116
|
+
identifier: "user@example.com",
|
|
117
|
+
method: "email",
|
|
118
|
+
code: "999999",
|
|
119
|
+
attempts: 1,
|
|
120
|
+
maxAttempts: 3,
|
|
121
|
+
status: "pending",
|
|
122
|
+
createdAt: new Date(),
|
|
123
|
+
expiresAt: new Date(Date.now() + 300000),
|
|
124
|
+
};
|
|
125
|
+
await sessionRepo.createSession(session);
|
|
126
|
+
|
|
127
|
+
// Act
|
|
128
|
+
const updated = await sessionRepo.markAsVerified("verify-test");
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
expect(updated).toBeDefined();
|
|
132
|
+
expect(updated?.status).toBe("verified");
|
|
133
|
+
expect(updated?.verifiedAt).toBeDefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should mark session as expired", async () => {
|
|
137
|
+
// Arrange
|
|
138
|
+
const session: Omit<OtpSession, "_id"> = {
|
|
139
|
+
sessionId: "expire-test",
|
|
140
|
+
identifier: "+46701234567",
|
|
141
|
+
method: "sms",
|
|
142
|
+
code: "888888",
|
|
143
|
+
attempts: 0,
|
|
144
|
+
maxAttempts: 3,
|
|
145
|
+
status: "pending",
|
|
146
|
+
createdAt: new Date(),
|
|
147
|
+
expiresAt: new Date(Date.now() - 1000), // Already expired
|
|
148
|
+
};
|
|
149
|
+
await sessionRepo.createSession(session);
|
|
150
|
+
|
|
151
|
+
// Act
|
|
152
|
+
const updated = await sessionRepo.markAsExpired("expire-test");
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
155
|
+
expect(updated).toBeDefined();
|
|
156
|
+
expect(updated?.status).toBe("expired");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should return null when session not found", async () => {
|
|
160
|
+
// Act
|
|
161
|
+
const found = await sessionRepo.getSession("non-existent-session");
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
164
|
+
expect(found).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should handle custom payload in session", async () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
const session: Omit<OtpSession, "_id"> = {
|
|
170
|
+
sessionId: "payload-test",
|
|
171
|
+
identifier: "user@example.com",
|
|
172
|
+
method: "email",
|
|
173
|
+
code: "777777",
|
|
174
|
+
attempts: 0,
|
|
175
|
+
maxAttempts: 3,
|
|
176
|
+
status: "pending",
|
|
177
|
+
createdAt: new Date(),
|
|
178
|
+
expiresAt: new Date(Date.now() + 300000),
|
|
179
|
+
payload: {
|
|
180
|
+
action: "password-reset",
|
|
181
|
+
returnUrl: "/new-password",
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Act
|
|
186
|
+
const created = await sessionRepo.createSession(session);
|
|
187
|
+
const found = await sessionRepo.getSession("payload-test");
|
|
188
|
+
|
|
189
|
+
// Assert
|
|
190
|
+
expect(found?.payload).toBeDefined();
|
|
191
|
+
expect(found?.payload?.action).toBe("password-reset");
|
|
192
|
+
expect(found?.payload?.returnUrl).toBe("/new-password");
|
|
193
|
+
});
|
|
194
|
+
});
|