@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,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTP Utils Test Suite
|
|
3
|
+
*
|
|
4
|
+
* Tests for OTP code generation, identifier validation, and normalization
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
generateOtpCode,
|
|
9
|
+
generateSessionId,
|
|
10
|
+
validateIdentifier,
|
|
11
|
+
normalizePhoneNumber,
|
|
12
|
+
normalizeEmail,
|
|
13
|
+
normalizeIdentifier,
|
|
14
|
+
} from "../src/utils/otp-utils";
|
|
15
|
+
|
|
16
|
+
describe("OTP Utils", () => {
|
|
17
|
+
describe("OTP Code Generation", () => {
|
|
18
|
+
it("should generate a 6-digit code by default", () => {
|
|
19
|
+
const code = generateOtpCode();
|
|
20
|
+
|
|
21
|
+
expect(code).toBeDefined();
|
|
22
|
+
expect(typeof code).toBe("string");
|
|
23
|
+
expect(code.length).toBe(6);
|
|
24
|
+
expect(/^\d{6}$/.test(code)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should generate a 4-digit code", () => {
|
|
28
|
+
const code = generateOtpCode(4);
|
|
29
|
+
|
|
30
|
+
expect(code.length).toBe(4);
|
|
31
|
+
expect(/^\d{4}$/.test(code)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should generate an 8-digit code", () => {
|
|
35
|
+
const code = generateOtpCode(8);
|
|
36
|
+
|
|
37
|
+
expect(code.length).toBe(8);
|
|
38
|
+
expect(/^\d{8}$/.test(code)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should generate unique codes", () => {
|
|
42
|
+
const code1 = generateOtpCode();
|
|
43
|
+
const code2 = generateOtpCode();
|
|
44
|
+
|
|
45
|
+
// Very unlikely to be the same (1 in 1,000,000)
|
|
46
|
+
expect(code1).not.toBe(code2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should throw error for code length < 4", () => {
|
|
50
|
+
expect(() => generateOtpCode(3)).toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should throw error for code length > 8", () => {
|
|
54
|
+
expect(() => generateOtpCode(9)).toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should generate codes within valid range", () => {
|
|
58
|
+
// 6-digit code should be between 100000 and 999999
|
|
59
|
+
for (let i = 0; i < 10; i++) {
|
|
60
|
+
const code = generateOtpCode(6);
|
|
61
|
+
const num = parseInt(code, 10);
|
|
62
|
+
expect(num).toBeGreaterThanOrEqual(100000);
|
|
63
|
+
expect(num).toBeLessThanOrEqual(999999);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("Session ID Generation", () => {
|
|
69
|
+
it("should generate a 32-character hex session ID", () => {
|
|
70
|
+
const sessionId = generateSessionId();
|
|
71
|
+
|
|
72
|
+
expect(sessionId).toBeDefined();
|
|
73
|
+
expect(typeof sessionId).toBe("string");
|
|
74
|
+
expect(sessionId.length).toBe(32); // 16 bytes = 32 hex chars
|
|
75
|
+
expect(/^[0-9a-f]{32}$/.test(sessionId)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should generate unique session IDs", () => {
|
|
79
|
+
const id1 = generateSessionId();
|
|
80
|
+
const id2 = generateSessionId();
|
|
81
|
+
|
|
82
|
+
expect(id1).not.toBe(id2);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("Identifier Validation", () => {
|
|
87
|
+
describe("Email Validation", () => {
|
|
88
|
+
it("should validate correct email addresses", () => {
|
|
89
|
+
expect(validateIdentifier("user@example.com", "email")).toBe(true);
|
|
90
|
+
expect(validateIdentifier("test.user@company.co.uk", "email")).toBe(true);
|
|
91
|
+
expect(validateIdentifier("admin+tag@domain.org", "email")).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should reject invalid email addresses", () => {
|
|
95
|
+
expect(validateIdentifier("notanemail", "email")).toBe(false);
|
|
96
|
+
expect(validateIdentifier("missing@domain", "email")).toBe(false);
|
|
97
|
+
expect(validateIdentifier("@example.com", "email")).toBe(false);
|
|
98
|
+
expect(validateIdentifier("user@", "email")).toBe(false);
|
|
99
|
+
expect(validateIdentifier("", "email")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("Phone Number Validation", () => {
|
|
104
|
+
it("should validate correct phone numbers", () => {
|
|
105
|
+
expect(validateIdentifier("+46701234567", "sms")).toBe(true);
|
|
106
|
+
expect(validateIdentifier("555-123-4567", "sms")).toBe(true);
|
|
107
|
+
expect(validateIdentifier("+1 (555) 123-4567", "sms")).toBe(true);
|
|
108
|
+
expect(validateIdentifier("5551234567", "sms")).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should reject invalid phone numbers", () => {
|
|
112
|
+
expect(validateIdentifier("abc", "sms")).toBe(false);
|
|
113
|
+
expect(validateIdentifier("123", "sms")).toBe(false); // Too short
|
|
114
|
+
expect(validateIdentifier("", "sms")).toBe(false);
|
|
115
|
+
expect(validateIdentifier("12345678901234567890123", "sms")).toBe(false); // Too long
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should reject null/undefined identifiers", () => {
|
|
120
|
+
expect(validateIdentifier(null as any, "email")).toBe(false);
|
|
121
|
+
expect(validateIdentifier(undefined as any, "email")).toBe(false);
|
|
122
|
+
expect(validateIdentifier(null as any, "sms")).toBe(false);
|
|
123
|
+
expect(validateIdentifier(undefined as any, "sms")).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("Phone Number Normalization", () => {
|
|
128
|
+
it("should remove formatting characters from phone numbers", () => {
|
|
129
|
+
expect(normalizePhoneNumber("+1 (555) 123-4567")).toBe("+15551234567");
|
|
130
|
+
expect(normalizePhoneNumber("555-123-4567")).toBe("5551234567");
|
|
131
|
+
expect(normalizePhoneNumber("+46 70 123 45 67")).toBe("+46701234567");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should preserve + prefix", () => {
|
|
135
|
+
expect(normalizePhoneNumber("+46701234567")).toBe("+46701234567");
|
|
136
|
+
expect(normalizePhoneNumber("+1234567890")).toBe("+1234567890");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should handle already normalized numbers", () => {
|
|
140
|
+
expect(normalizePhoneNumber("5551234567")).toBe("5551234567");
|
|
141
|
+
expect(normalizePhoneNumber("+15551234567")).toBe("+15551234567");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("Email Normalization", () => {
|
|
146
|
+
it("should convert email to lowercase", () => {
|
|
147
|
+
expect(normalizeEmail("User@Example.COM")).toBe("user@example.com");
|
|
148
|
+
expect(normalizeEmail("ADMIN@COMPANY.ORG")).toBe("admin@company.org");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("should trim whitespace", () => {
|
|
152
|
+
expect(normalizeEmail(" user@example.com ")).toBe("user@example.com");
|
|
153
|
+
expect(normalizeEmail("user@example.com\n")).toBe("user@example.com");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should handle already normalized emails", () => {
|
|
157
|
+
expect(normalizeEmail("user@example.com")).toBe("user@example.com");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("Identifier Normalization", () => {
|
|
162
|
+
it("should normalize email identifiers", () => {
|
|
163
|
+
expect(normalizeIdentifier("User@Example.COM", "email")).toBe("user@example.com");
|
|
164
|
+
expect(normalizeIdentifier(" test@domain.org ", "email")).toBe("test@domain.org");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should normalize phone identifiers", () => {
|
|
168
|
+
expect(normalizeIdentifier("+1 (555) 123-4567", "sms")).toBe("+15551234567");
|
|
169
|
+
expect(normalizeIdentifier("555-123-4567", "sms")).toBe("5551234567");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { FlinkApp, FlinkPlugin, log } from "@flink-app/flink";
|
|
2
|
+
import { Db } from "mongodb";
|
|
3
|
+
import { initiate } from "./functions/initiate";
|
|
4
|
+
import { verify } from "./functions/verify";
|
|
5
|
+
import * as PostOtpInitiate from "./handlers/PostOtpInitiate";
|
|
6
|
+
import * as PostOtpVerify from "./handlers/PostOtpVerify";
|
|
7
|
+
import { OtpAuthPluginContext } from "./OtpAuthPluginContext";
|
|
8
|
+
import { OtpAuthPluginOptions } from "./OtpAuthPluginOptions";
|
|
9
|
+
import { OtpInternalContext } from "./OtpInternalContext";
|
|
10
|
+
import OtpSessionRepo from "./repos/OtpSessionRepo";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* OTP Auth Plugin Factory Function
|
|
14
|
+
*
|
|
15
|
+
* Creates a Flink plugin for OTP (One-Time Password) authentication via SMS or email.
|
|
16
|
+
* Integrates with JWT Auth Plugin for token generation.
|
|
17
|
+
*
|
|
18
|
+
* @param options - OTP auth plugin configuration options
|
|
19
|
+
* @returns FlinkPlugin instance
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* import { jwtAuthPlugin } from '@flink-app/jwt-auth-plugin';
|
|
24
|
+
* import { otpAuthPlugin } from '@flink-app/otp-auth-plugin';
|
|
25
|
+
*
|
|
26
|
+
* const app = new FlinkApp({
|
|
27
|
+
* auth: jwtAuthPlugin({
|
|
28
|
+
* secret: process.env.JWT_SECRET!,
|
|
29
|
+
* getUser: async (tokenData) => {
|
|
30
|
+
* return ctx.repos.userRepo.getById(tokenData.userId);
|
|
31
|
+
* },
|
|
32
|
+
* rolePermissions: {
|
|
33
|
+
* user: ['read', 'write']
|
|
34
|
+
* }
|
|
35
|
+
* }),
|
|
36
|
+
*
|
|
37
|
+
* plugins: [
|
|
38
|
+
* otpAuthPlugin({
|
|
39
|
+
* codeLength: 6,
|
|
40
|
+
* codeTTL: 300, // 5 minutes
|
|
41
|
+
* maxAttempts: 3,
|
|
42
|
+
* onSendCode: async (code, identifier, method) => {
|
|
43
|
+
* if (method === 'sms') {
|
|
44
|
+
* await ctx.plugins.sms.send(identifier, `Your code: ${code}`);
|
|
45
|
+
* } else {
|
|
46
|
+
* await ctx.plugins.email.send({
|
|
47
|
+
* to: identifier,
|
|
48
|
+
* subject: 'Your verification code',
|
|
49
|
+
* text: `Your code: ${code}`
|
|
50
|
+
* });
|
|
51
|
+
* }
|
|
52
|
+
* return true;
|
|
53
|
+
* },
|
|
54
|
+
* onGetUser: async (identifier, method) => {
|
|
55
|
+
* if (method === 'sms') {
|
|
56
|
+
* return await ctx.repos.userRepo.findOne({ phoneNumber: identifier });
|
|
57
|
+
* } else {
|
|
58
|
+
* return await ctx.repos.userRepo.findOne({ email: identifier });
|
|
59
|
+
* }
|
|
60
|
+
* },
|
|
61
|
+
* onVerifySuccess: async (user, identifier, method) => {
|
|
62
|
+
* const token = await ctx.auth.createToken(
|
|
63
|
+
* { userId: user._id, email: user.email },
|
|
64
|
+
* user.roles
|
|
65
|
+
* );
|
|
66
|
+
* return { user, token };
|
|
67
|
+
* }
|
|
68
|
+
* })
|
|
69
|
+
* ]
|
|
70
|
+
* });
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function otpAuthPlugin(options: OtpAuthPluginOptions): FlinkPlugin {
|
|
74
|
+
// Validation
|
|
75
|
+
if (!options.onSendCode) {
|
|
76
|
+
throw new Error("OTP Auth Plugin: onSendCode callback is required");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!options.onGetUser) {
|
|
80
|
+
throw new Error("OTP Auth Plugin: onGetUser callback is required");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!options.onVerifySuccess) {
|
|
84
|
+
throw new Error("OTP Auth Plugin: onVerifySuccess callback is required");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Validate code length
|
|
88
|
+
const codeLength = options.codeLength || 6;
|
|
89
|
+
if (codeLength < 4 || codeLength > 8) {
|
|
90
|
+
throw new Error("OTP Auth Plugin: codeLength must be between 4 and 8");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Validate code TTL
|
|
94
|
+
const codeTTL = options.codeTTL || 300;
|
|
95
|
+
if (codeTTL < 30 || codeTTL > 3600) {
|
|
96
|
+
log.warn("OTP Auth Plugin: codeTTL should be between 30 and 3600 seconds for security");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Validate max attempts
|
|
100
|
+
const maxAttempts = options.maxAttempts || 3;
|
|
101
|
+
if (maxAttempts < 1 || maxAttempts > 10) {
|
|
102
|
+
throw new Error("OTP Auth Plugin: maxAttempts must be between 1 and 10");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let flinkApp: FlinkApp<OtpInternalContext>;
|
|
106
|
+
let sessionRepo: OtpSessionRepo;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Plugin initialization
|
|
110
|
+
*/
|
|
111
|
+
async function init(app: FlinkApp<any>, db?: Db) {
|
|
112
|
+
log.info("Initializing OTP Auth Plugin...");
|
|
113
|
+
|
|
114
|
+
flinkApp = app as FlinkApp<OtpInternalContext>;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
if (!db) {
|
|
118
|
+
throw new Error("OTP Auth Plugin: Database connection is required");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Initialize repository
|
|
122
|
+
const collectionName = options.otpSessionsCollectionName || "otp_sessions";
|
|
123
|
+
sessionRepo = new OtpSessionRepo(collectionName, db);
|
|
124
|
+
|
|
125
|
+
flinkApp.addRepo("otpSessionRepo", sessionRepo);
|
|
126
|
+
|
|
127
|
+
// Create TTL index for automatic session cleanup
|
|
128
|
+
const keepSessionsSec = options.keepSessionsSec || 86400; // Default 24 hours
|
|
129
|
+
await sessionRepo.ensureExpiringIndex(keepSessionsSec);
|
|
130
|
+
|
|
131
|
+
// Register OTP handlers
|
|
132
|
+
// Only register handlers if registerRoutes is enabled (default: true)
|
|
133
|
+
if (options.registerRoutes !== false) {
|
|
134
|
+
flinkApp.addHandler(PostOtpInitiate);
|
|
135
|
+
flinkApp.addHandler(PostOtpVerify);
|
|
136
|
+
log.info("OTP Auth Plugin: Registered HTTP endpoints");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
log.info(`OTP Auth Plugin initialized (codeLength: ${codeLength}, codeTTL: ${codeTTL}s, maxAttempts: ${maxAttempts})`);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
log.error("Failed to initialize OTP Auth Plugin:", error);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Plugin context exposed via ctx.plugins.otpAuth
|
|
148
|
+
*/
|
|
149
|
+
const pluginCtx: OtpAuthPluginContext["otpAuth"] = {
|
|
150
|
+
options: Object.freeze({ ...options }),
|
|
151
|
+
initiate: (initiateOptions) => initiate(flinkApp.ctx as OtpInternalContext, initiateOptions),
|
|
152
|
+
verify: (verifyOptions) => verify(flinkApp.ctx as OtpInternalContext, verifyOptions),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
id: "otpAuth",
|
|
157
|
+
db: {
|
|
158
|
+
useHostDb: true,
|
|
159
|
+
},
|
|
160
|
+
ctx: pluginCtx,
|
|
161
|
+
init,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { OtpAuthPluginOptions } from "./OtpAuthPluginOptions";
|
|
2
|
+
import { InitiateOptions, InitiateResponse } from "./functions/initiate";
|
|
3
|
+
import { VerifyOptions, VerifyResponse } from "./functions/verify";
|
|
4
|
+
|
|
5
|
+
export interface OtpAuthPluginContext {
|
|
6
|
+
otpAuth: {
|
|
7
|
+
options: OtpAuthPluginOptions;
|
|
8
|
+
initiate: (options: InitiateOptions) => Promise<InitiateResponse>;
|
|
9
|
+
verify: (options: VerifyOptions) => Promise<VerifyResponse>;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
export interface AuthSuccessCallbackResponse {
|
|
2
|
+
user: any;
|
|
3
|
+
token: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface OtpAuthPluginOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Number of digits in the OTP code (4-8).
|
|
9
|
+
* Default is 6.
|
|
10
|
+
*/
|
|
11
|
+
codeLength?: number;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How long the OTP code is valid in seconds.
|
|
15
|
+
* Default is 300 (5 minutes).
|
|
16
|
+
*/
|
|
17
|
+
codeTTL?: number;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Maximum number of verification attempts before session is locked.
|
|
21
|
+
* Default is 3.
|
|
22
|
+
*/
|
|
23
|
+
maxAttempts?: number;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Callback to send the OTP code via SMS or email.
|
|
27
|
+
* Should return true if sending succeeded, false otherwise.
|
|
28
|
+
*
|
|
29
|
+
* @param code - The OTP code to send
|
|
30
|
+
* @param identifier - The user identifier (phone number or email)
|
|
31
|
+
* @param method - The delivery method ('sms' or 'email')
|
|
32
|
+
* @param payload - Optional custom payload from initiation
|
|
33
|
+
* @returns Promise that resolves to true if sent successfully
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* onSendCode: async (code, identifier, method, payload) => {
|
|
38
|
+
* if (method === 'sms') {
|
|
39
|
+
* await ctx.plugins.sms.send(identifier, `Your code is: ${code}`);
|
|
40
|
+
* } else {
|
|
41
|
+
* await ctx.plugins.email.send({
|
|
42
|
+
* to: identifier,
|
|
43
|
+
* subject: 'Your verification code',
|
|
44
|
+
* text: `Your code is: ${code}`
|
|
45
|
+
* });
|
|
46
|
+
* }
|
|
47
|
+
* return true;
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
onSendCode: (
|
|
52
|
+
code: string,
|
|
53
|
+
identifier: string,
|
|
54
|
+
method: "sms" | "email",
|
|
55
|
+
payload?: Record<string, any>
|
|
56
|
+
) => Promise<boolean>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Callback to retrieve user by identifier (phone number or email).
|
|
60
|
+
* Return null if user is not found.
|
|
61
|
+
*
|
|
62
|
+
* @param identifier - The user identifier (phone number or email)
|
|
63
|
+
* @param method - The delivery method ('sms' or 'email')
|
|
64
|
+
* @param payload - Optional custom payload from initiation
|
|
65
|
+
* @returns Promise that resolves to user object or null
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* onGetUser: async (identifier, method) => {
|
|
70
|
+
* if (method === 'sms') {
|
|
71
|
+
* return await ctx.repos.userRepo.findOne({ phoneNumber: identifier });
|
|
72
|
+
* } else {
|
|
73
|
+
* return await ctx.repos.userRepo.findOne({ email: identifier });
|
|
74
|
+
* }
|
|
75
|
+
* }
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
onGetUser: (
|
|
79
|
+
identifier: string,
|
|
80
|
+
method: "sms" | "email",
|
|
81
|
+
payload?: Record<string, any>
|
|
82
|
+
) => Promise<any | null>;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Callback invoked when OTP verification is successful.
|
|
86
|
+
* Must return user object and JWT token.
|
|
87
|
+
*
|
|
88
|
+
* @param user - The user object returned from onGetUser
|
|
89
|
+
* @param identifier - The user identifier (phone number or email)
|
|
90
|
+
* @param method - The delivery method ('sms' or 'email')
|
|
91
|
+
* @param payload - Optional custom payload from initiation
|
|
92
|
+
* @returns Promise that resolves to user and token
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```typescript
|
|
96
|
+
* onVerifySuccess: async (user, identifier, method, payload) => {
|
|
97
|
+
* const token = await ctx.auth.createToken(
|
|
98
|
+
* { userId: user._id, email: user.email },
|
|
99
|
+
* user.roles
|
|
100
|
+
* );
|
|
101
|
+
* return { user, token };
|
|
102
|
+
* }
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
onVerifySuccess: (
|
|
106
|
+
user: any,
|
|
107
|
+
identifier: string,
|
|
108
|
+
method: "sms" | "email",
|
|
109
|
+
payload?: Record<string, any>
|
|
110
|
+
) => Promise<AuthSuccessCallbackResponse>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* For how long to keep sessions in database (in seconds).
|
|
114
|
+
* This is for data retention purposes only.
|
|
115
|
+
*
|
|
116
|
+
* An expiring index will be created in the database to automatically
|
|
117
|
+
* remove old sessions based on this.
|
|
118
|
+
*
|
|
119
|
+
* Default is 86400 (24 hours).
|
|
120
|
+
*/
|
|
121
|
+
keepSessionsSec?: number;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The name of the MongoDB collection to use for storing OTP sessions.
|
|
125
|
+
* Default is "otp_sessions".
|
|
126
|
+
*/
|
|
127
|
+
otpSessionsCollectionName?: string;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether to register the default HTTP routes for OTP operations.
|
|
131
|
+
* If false, you'll need to implement your own handlers using the functions.
|
|
132
|
+
* Default is true.
|
|
133
|
+
*/
|
|
134
|
+
registerRoutes?: boolean;
|
|
135
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { FlinkContext } from "@flink-app/flink";
|
|
2
|
+
import { OtpAuthPluginContext } from "./OtpAuthPluginContext";
|
|
3
|
+
import OtpSessionRepo from "./repos/OtpSessionRepo";
|
|
4
|
+
|
|
5
|
+
export interface OtpInternalContext extends FlinkContext {
|
|
6
|
+
plugins: {
|
|
7
|
+
otpAuth: OtpAuthPluginContext["otpAuth"];
|
|
8
|
+
};
|
|
9
|
+
repos: {
|
|
10
|
+
otpSessionRepo: OtpSessionRepo;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { badRequest, log } from "@flink-app/flink";
|
|
2
|
+
import { OtpInternalContext } from "../OtpInternalContext";
|
|
3
|
+
import { generateOtpCode, generateSessionId, normalizeIdentifier, validateIdentifier } from "../utils/otp-utils";
|
|
4
|
+
import OtpSession from "../schemas/OtpSession";
|
|
5
|
+
|
|
6
|
+
export interface InitiateOptions {
|
|
7
|
+
/** User identifier (phone number or email) */
|
|
8
|
+
identifier: string;
|
|
9
|
+
/** Delivery method for the OTP code */
|
|
10
|
+
method: "sms" | "email";
|
|
11
|
+
/** Optional custom payload to attach to the session */
|
|
12
|
+
payload?: Record<string, any>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface InitiateResponse {
|
|
16
|
+
/** Unique session ID for this OTP session */
|
|
17
|
+
sessionId: string;
|
|
18
|
+
/** When the code expires */
|
|
19
|
+
expiresAt: Date;
|
|
20
|
+
/** Time-to-live in seconds */
|
|
21
|
+
ttl: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function initiate(ctx: OtpInternalContext, options: InitiateOptions): Promise<InitiateResponse> {
|
|
25
|
+
const { identifier, method, payload } = options;
|
|
26
|
+
const { options: pluginOptions } = ctx.plugins.otpAuth;
|
|
27
|
+
|
|
28
|
+
// Validate identifier format
|
|
29
|
+
if (!validateIdentifier(identifier, method)) {
|
|
30
|
+
log.warn(`Invalid identifier format for method ${method}: ${identifier}`);
|
|
31
|
+
throw badRequest(`Invalid ${method === "email" ? "email address" : "phone number"} format`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Normalize identifier (lowercase email, remove phone formatting)
|
|
35
|
+
const normalizedIdentifier = normalizeIdentifier(identifier, method);
|
|
36
|
+
|
|
37
|
+
// Generate OTP code
|
|
38
|
+
const codeLength = pluginOptions.codeLength || 6;
|
|
39
|
+
if (codeLength < 4 || codeLength > 8) {
|
|
40
|
+
throw new Error("OTP code length must be between 4 and 8 digits");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const code = generateOtpCode(codeLength);
|
|
44
|
+
const sessionId = generateSessionId();
|
|
45
|
+
|
|
46
|
+
// Calculate expiration
|
|
47
|
+
const codeTTL = pluginOptions.codeTTL || 300; // Default 5 minutes
|
|
48
|
+
const expiresAt = new Date(Date.now() + codeTTL * 1000);
|
|
49
|
+
|
|
50
|
+
// Create session
|
|
51
|
+
const session: Omit<OtpSession, "_id"> = {
|
|
52
|
+
sessionId,
|
|
53
|
+
identifier: normalizedIdentifier,
|
|
54
|
+
method,
|
|
55
|
+
code,
|
|
56
|
+
attempts: 0,
|
|
57
|
+
maxAttempts: pluginOptions.maxAttempts || 3,
|
|
58
|
+
status: "pending",
|
|
59
|
+
createdAt: new Date(),
|
|
60
|
+
expiresAt,
|
|
61
|
+
payload,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
await ctx.repos.otpSessionRepo.createSession(session);
|
|
65
|
+
|
|
66
|
+
// Send the code via callback
|
|
67
|
+
try {
|
|
68
|
+
const sent = await pluginOptions.onSendCode(code, normalizedIdentifier, method, payload);
|
|
69
|
+
|
|
70
|
+
if (!sent) {
|
|
71
|
+
log.error(`Failed to send OTP code to ${normalizedIdentifier} via ${method}`);
|
|
72
|
+
throw new Error("Failed to send verification code");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
log.info(`OTP code sent to ${normalizedIdentifier} via ${method} (session: ${sessionId})`);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
log.error(`Error sending OTP code: ${error}`);
|
|
78
|
+
throw new Error("Failed to send verification code");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
sessionId,
|
|
83
|
+
expiresAt,
|
|
84
|
+
ttl: codeTTL,
|
|
85
|
+
};
|
|
86
|
+
}
|