@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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +879 -0
  3. package/dist/OtpAuthPlugin.d.ts +64 -0
  4. package/dist/OtpAuthPlugin.js +231 -0
  5. package/dist/OtpAuthPluginContext.d.ts +10 -0
  6. package/dist/OtpAuthPluginContext.js +2 -0
  7. package/dist/OtpAuthPluginOptions.d.ts +112 -0
  8. package/dist/OtpAuthPluginOptions.js +2 -0
  9. package/dist/OtpInternalContext.d.ts +11 -0
  10. package/dist/OtpInternalContext.js +2 -0
  11. package/dist/functions/initiate.d.ts +18 -0
  12. package/dist/functions/initiate.js +104 -0
  13. package/dist/functions/verify.d.ts +20 -0
  14. package/dist/functions/verify.js +142 -0
  15. package/dist/handlers/PostOtpInitiate.d.ts +7 -0
  16. package/dist/handlers/PostOtpInitiate.js +70 -0
  17. package/dist/handlers/PostOtpVerify.d.ts +7 -0
  18. package/dist/handlers/PostOtpVerify.js +86 -0
  19. package/dist/index.d.ts +11 -0
  20. package/dist/index.js +24 -0
  21. package/dist/repos/OtpSessionRepo.d.ts +13 -0
  22. package/dist/repos/OtpSessionRepo.js +145 -0
  23. package/dist/schemas/InitiateRequest.d.ts +8 -0
  24. package/dist/schemas/InitiateRequest.js +2 -0
  25. package/dist/schemas/InitiateResponse.d.ts +8 -0
  26. package/dist/schemas/InitiateResponse.js +2 -0
  27. package/dist/schemas/OtpSession.d.ts +25 -0
  28. package/dist/schemas/OtpSession.js +2 -0
  29. package/dist/schemas/VerifyRequest.d.ts +6 -0
  30. package/dist/schemas/VerifyRequest.js +2 -0
  31. package/dist/schemas/VerifyResponse.d.ts +12 -0
  32. package/dist/schemas/VerifyResponse.js +2 -0
  33. package/dist/utils/otp-utils.d.ts +43 -0
  34. package/dist/utils/otp-utils.js +95 -0
  35. package/examples/basic-usage.ts +145 -0
  36. package/package.json +37 -0
  37. package/spec/OtpAuthPlugin.spec.ts +159 -0
  38. package/spec/OtpSessionRepo.spec.ts +194 -0
  39. package/spec/otp-utils.spec.ts +172 -0
  40. package/spec/support/jasmine.json +7 -0
  41. package/src/OtpAuthPlugin.ts +163 -0
  42. package/src/OtpAuthPluginContext.ts +11 -0
  43. package/src/OtpAuthPluginOptions.ts +135 -0
  44. package/src/OtpInternalContext.ts +12 -0
  45. package/src/functions/initiate.ts +86 -0
  46. package/src/functions/verify.ts +123 -0
  47. package/src/handlers/PostOtpInitiate.ts +28 -0
  48. package/src/handlers/PostOtpVerify.ts +42 -0
  49. package/src/index.ts +17 -0
  50. package/src/repos/OtpSessionRepo.ts +47 -0
  51. package/src/schemas/InitiateRequest.ts +8 -0
  52. package/src/schemas/InitiateResponse.ts +8 -0
  53. package/src/schemas/OtpSession.ts +25 -0
  54. package/src/schemas/VerifyRequest.ts +6 -0
  55. package/src/schemas/VerifyResponse.ts +12 -0
  56. package/src/utils/otp-utils.ts +89 -0
  57. package/tsconfig.dist.json +4 -0
  58. package/tsconfig.json +24 -0
@@ -0,0 +1,123 @@
1
+ import { badRequest, log, notFound } from "@flink-app/flink";
2
+ import { OtpInternalContext } from "../OtpInternalContext";
3
+
4
+ export interface VerifyOptions {
5
+ /** The session ID from the initiate response */
6
+ sessionId: string;
7
+ /** The OTP code entered by the user */
8
+ code: string;
9
+ }
10
+
11
+ export interface VerifyResponse {
12
+ /** Verification status */
13
+ status: "success" | "invalid_code" | "expired" | "locked" | "not_found";
14
+ /** JWT token if successful */
15
+ token?: string;
16
+ /** User object if successful */
17
+ user?: any;
18
+ /** Remaining attempts if code was invalid */
19
+ remainingAttempts?: number;
20
+ /** Error message */
21
+ message?: string;
22
+ }
23
+
24
+ export async function verify(ctx: OtpInternalContext, options: VerifyOptions): Promise<VerifyResponse> {
25
+ const { sessionId, code } = options;
26
+ const { options: pluginOptions } = ctx.plugins.otpAuth;
27
+
28
+ // Validate input
29
+ if (!sessionId || !code) {
30
+ throw badRequest("Session ID and code are required");
31
+ }
32
+
33
+ // Find session
34
+ const session = await ctx.repos.otpSessionRepo.getSession(sessionId);
35
+
36
+ if (!session) {
37
+ log.warn(`OTP session not found: ${sessionId}`);
38
+ return {
39
+ status: "not_found",
40
+ message: "Session not found or expired",
41
+ };
42
+ }
43
+
44
+ // Check if session is already verified
45
+ if (session.status === "verified") {
46
+ log.warn(`Attempt to verify already verified session: ${sessionId}`);
47
+ throw badRequest("Session already verified");
48
+ }
49
+
50
+ // Check if session is locked
51
+ if (session.status === "locked") {
52
+ log.warn(`Attempt to verify locked session: ${sessionId}`);
53
+ return {
54
+ status: "locked",
55
+ message: "Too many failed attempts. Session is locked.",
56
+ };
57
+ }
58
+
59
+ // Check if session has expired
60
+ if (session.status === "expired" || new Date() > session.expiresAt) {
61
+ if (session.status !== "expired") {
62
+ await ctx.repos.otpSessionRepo.markAsExpired(sessionId);
63
+ }
64
+ log.warn(`Attempt to verify expired session: ${sessionId}`);
65
+ return {
66
+ status: "expired",
67
+ message: "Verification code has expired",
68
+ };
69
+ }
70
+
71
+ // Verify the code
72
+ const codeMatches = session.code === code;
73
+
74
+ if (!codeMatches) {
75
+ // Increment attempts
76
+ const updatedSession = await ctx.repos.otpSessionRepo.incrementAttempts(sessionId);
77
+
78
+ const remainingAttempts = updatedSession!.maxAttempts - updatedSession!.attempts;
79
+
80
+ log.warn(`Invalid OTP code for session ${sessionId}. Remaining attempts: ${remainingAttempts}`);
81
+
82
+ if (updatedSession!.status === "locked") {
83
+ return {
84
+ status: "locked",
85
+ message: "Too many failed attempts. Session is locked.",
86
+ remainingAttempts: 0,
87
+ };
88
+ }
89
+
90
+ return {
91
+ status: "invalid_code",
92
+ message: "Invalid verification code",
93
+ remainingAttempts,
94
+ };
95
+ }
96
+
97
+ // Code is valid - mark session as verified
98
+ await ctx.repos.otpSessionRepo.markAsVerified(sessionId);
99
+
100
+ // Get user via callback
101
+ const user = await pluginOptions.onGetUser(session.identifier, session.method, session.payload);
102
+
103
+ if (!user) {
104
+ log.error(`User not found for identifier ${session.identifier} after successful OTP verification`);
105
+ throw notFound("User not found");
106
+ }
107
+
108
+ // Generate token via callback
109
+ try {
110
+ const authResult = await pluginOptions.onVerifySuccess(user, session.identifier, session.method, session.payload);
111
+
112
+ log.info(`OTP verification successful for session ${sessionId}, identifier: ${session.identifier}`);
113
+
114
+ return {
115
+ status: "success",
116
+ token: authResult.token,
117
+ user: authResult.user,
118
+ };
119
+ } catch (error) {
120
+ log.error(`Error in onVerifySuccess callback: ${error}`);
121
+ throw new Error("Failed to complete authentication");
122
+ }
123
+ }
@@ -0,0 +1,28 @@
1
+ import { Handler, HttpMethod, RouteProps, internalServerError } from "@flink-app/flink";
2
+ import InitiateRequest from "../schemas/InitiateRequest";
3
+ import InitiateResponse from "../schemas/InitiateResponse";
4
+ import { OtpInternalContext } from "../OtpInternalContext";
5
+ import { initiate } from "../functions/initiate";
6
+
7
+ export const Route: RouteProps = {
8
+ path: "/otp/initiate",
9
+ method: HttpMethod.post,
10
+ };
11
+
12
+ const PostOtpInitiate: Handler<OtpInternalContext, InitiateRequest, InitiateResponse> = async ({ ctx, req }) => {
13
+ try {
14
+ const response = await initiate(ctx, {
15
+ identifier: req.body.identifier,
16
+ method: req.body.method,
17
+ payload: req.body.payload,
18
+ });
19
+
20
+ return {
21
+ data: response,
22
+ };
23
+ } catch (error: any) {
24
+ return internalServerError(error.message || "Failed to initiate OTP authentication");
25
+ }
26
+ };
27
+
28
+ export default PostOtpInitiate;
@@ -0,0 +1,42 @@
1
+ import { Handler, HttpMethod, RouteProps } from "@flink-app/flink";
2
+ import { OtpInternalContext } from "../OtpInternalContext";
3
+ import { verify } from "../functions/verify";
4
+ import VerifyRequest from "../schemas/VerifyRequest";
5
+ import VerifyResponse from "../schemas/VerifyResponse";
6
+
7
+ export const Route: RouteProps = {
8
+ path: "/otp/verify",
9
+ method: HttpMethod.post,
10
+ };
11
+
12
+ const PostOtpVerify: Handler<OtpInternalContext, VerifyRequest, VerifyResponse> = async ({ ctx, req }) => {
13
+ const response = await verify(ctx, {
14
+ sessionId: req.body.sessionId,
15
+ code: req.body.code,
16
+ });
17
+
18
+ // Return appropriate HTTP status based on verification result
19
+ if (response.status === "success") {
20
+ return {
21
+ data: response,
22
+ };
23
+ } else if (response.status === "not_found") {
24
+ return {
25
+ status: 404,
26
+ data: response,
27
+ };
28
+ } else if (response.status === "locked" || response.status === "expired") {
29
+ return {
30
+ status: 403,
31
+ data: response,
32
+ };
33
+ } else {
34
+ // invalid_code
35
+ return {
36
+ status: 401,
37
+ data: response,
38
+ };
39
+ }
40
+ };
41
+
42
+ export default PostOtpVerify;
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export * from "./OtpAuthPlugin";
2
+ export * from "./OtpAuthPluginContext";
3
+ export * from "./OtpAuthPluginOptions";
4
+
5
+ // Functions
6
+ export * from "./functions/initiate";
7
+ export * from "./functions/verify";
8
+
9
+ // Schemas
10
+ export type { default as OtpSession } from "./schemas/OtpSession";
11
+ export type { default as InitiateRequest } from "./schemas/InitiateRequest";
12
+ export type { default as InitiateResponse } from "./schemas/InitiateResponse";
13
+ export type { default as VerifyRequest } from "./schemas/VerifyRequest";
14
+ export type { default as VerifyResponse } from "./schemas/VerifyResponse";
15
+
16
+ // Utils
17
+ export * from "./utils/otp-utils";
@@ -0,0 +1,47 @@
1
+ import { FlinkRepo, log } from "@flink-app/flink";
2
+ import OtpSession from "../schemas/OtpSession";
3
+
4
+ class OtpSessionRepo extends FlinkRepo<any, OtpSession> {
5
+ async getSession(sessionId: string) {
6
+ return this.getOne({ sessionId });
7
+ }
8
+
9
+ async createSession(session: Omit<OtpSession, "_id">) {
10
+ return await this.create(session);
11
+ }
12
+
13
+ async incrementAttempts(sessionId: string) {
14
+ const session = await this.getSession(sessionId);
15
+ if (!session) {
16
+ throw new Error("Session not found");
17
+ }
18
+
19
+ const newAttempts = session.attempts + 1;
20
+ const newStatus = newAttempts >= session.maxAttempts ? "locked" : session.status;
21
+
22
+ await this.collection.updateOne({ sessionId }, { $set: { attempts: newAttempts, status: newStatus } });
23
+
24
+ return this.getSession(sessionId);
25
+ }
26
+
27
+ async markAsVerified(sessionId: string) {
28
+ await this.collection.updateOne({ sessionId }, { $set: { status: "verified", verifiedAt: new Date() } });
29
+ return this.getSession(sessionId);
30
+ }
31
+
32
+ async markAsExpired(sessionId: string) {
33
+ await this.collection.updateOne({ sessionId }, { $set: { status: "expired" } });
34
+ return this.getSession(sessionId);
35
+ }
36
+
37
+ async ensureExpiringIndex(ttlSec: number) {
38
+ try {
39
+ await this.collection.createIndex({ createdAt: 1 }, { expireAfterSeconds: ttlSec });
40
+ log.info(`OTP Auth Plugin: Created TTL index with ${ttlSec}s expiration`);
41
+ } catch (err) {
42
+ log.error("Error creating expiring index for OTP sessions:", err);
43
+ }
44
+ }
45
+ }
46
+
47
+ export default OtpSessionRepo;
@@ -0,0 +1,8 @@
1
+ export default interface InitiateRequest {
2
+ /** User identifier (phone number or email) */
3
+ identifier: string;
4
+ /** Delivery method for the OTP code */
5
+ method: "sms" | "email";
6
+ /** Optional custom payload to attach to the session */
7
+ payload?: Record<string, any>;
8
+ }
@@ -0,0 +1,8 @@
1
+ export default interface InitiateResponse {
2
+ /** Unique session ID for this OTP session */
3
+ sessionId: string;
4
+ /** When the code expires */
5
+ expiresAt: Date;
6
+ /** Time-to-live in seconds */
7
+ ttl: number;
8
+ }
@@ -0,0 +1,25 @@
1
+ export default interface OtpSession {
2
+ _id?: string;
3
+ /** Unique identifier for the OTP session */
4
+ sessionId: string;
5
+ /** User identifier (phone number, email, or user ID) */
6
+ identifier: string;
7
+ /** The delivery method for this session */
8
+ method: "sms" | "email";
9
+ /** The generated OTP code */
10
+ code: string;
11
+ /** Number of verification attempts made */
12
+ attempts: number;
13
+ /** Maximum allowed attempts before session is locked */
14
+ maxAttempts: number;
15
+ /** Session status */
16
+ status: "pending" | "verified" | "expired" | "locked";
17
+ /** When the session was created */
18
+ createdAt: Date;
19
+ /** When the session expires */
20
+ expiresAt: Date;
21
+ /** When the session was verified (if verified) */
22
+ verifiedAt?: Date;
23
+ /** Optional custom payload passed during initiation */
24
+ payload?: Record<string, any>;
25
+ }
@@ -0,0 +1,6 @@
1
+ export default interface VerifyRequest {
2
+ /** The session ID from the initiate response */
3
+ sessionId: string;
4
+ /** The OTP code entered by the user */
5
+ code: string;
6
+ }
@@ -0,0 +1,12 @@
1
+ export default interface VerifyResponse {
2
+ /** Verification status */
3
+ status: "success" | "invalid_code" | "expired" | "locked" | "not_found";
4
+ /** JWT token if successful */
5
+ token?: string;
6
+ /** User object if successful */
7
+ user?: any;
8
+ /** Remaining attempts if code was invalid */
9
+ remainingAttempts?: number;
10
+ /** Error message */
11
+ message?: string;
12
+ }
@@ -0,0 +1,89 @@
1
+ import crypto from "crypto";
2
+
3
+ /**
4
+ * Generates a random numeric OTP code.
5
+ *
6
+ * @param length - Number of digits (4-8)
7
+ * @returns A numeric string of the specified length
8
+ */
9
+ export function generateOtpCode(length: number = 6): string {
10
+ if (length < 4 || length > 8) {
11
+ throw new Error("OTP code length must be between 4 and 8 digits");
12
+ }
13
+
14
+ // Generate a random number with the specified number of digits
15
+ const min = Math.pow(10, length - 1);
16
+ const max = Math.pow(10, length) - 1;
17
+
18
+ // Use crypto.randomInt for cryptographically secure random numbers
19
+ return crypto.randomInt(min, max + 1).toString();
20
+ }
21
+
22
+ /**
23
+ * Generates a unique session ID.
24
+ *
25
+ * @returns A cryptographically secure random hex string
26
+ */
27
+ export function generateSessionId(): string {
28
+ return crypto.randomBytes(16).toString("hex");
29
+ }
30
+
31
+ /**
32
+ * Validates that an identifier looks like a valid phone number or email.
33
+ *
34
+ * @param identifier - The identifier to validate
35
+ * @param method - The expected delivery method
36
+ * @returns true if valid, false otherwise
37
+ */
38
+ export function validateIdentifier(identifier: string, method: "sms" | "email"): boolean {
39
+ if (!identifier || typeof identifier !== "string") {
40
+ return false;
41
+ }
42
+
43
+ if (method === "email") {
44
+ // Basic email validation
45
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
46
+ return emailRegex.test(identifier);
47
+ } else {
48
+ // Phone number validation (allow various formats)
49
+ // This accepts: digits, spaces, dashes, parentheses, and + prefix
50
+ const phoneRegex = /^\+?[\d\s\-()]{8,20}$/;
51
+ return phoneRegex.test(identifier);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Normalizes a phone number by removing formatting characters.
57
+ *
58
+ * @param phoneNumber - The phone number to normalize
59
+ * @returns Normalized phone number (digits and + only)
60
+ */
61
+ export function normalizePhoneNumber(phoneNumber: string): string {
62
+ // Remove all characters except digits and +
63
+ return phoneNumber.replace(/[^\d+]/g, "");
64
+ }
65
+
66
+ /**
67
+ * Normalizes an email by converting to lowercase and trimming.
68
+ *
69
+ * @param email - The email to normalize
70
+ * @returns Normalized email
71
+ */
72
+ export function normalizeEmail(email: string): string {
73
+ return email.toLowerCase().trim();
74
+ }
75
+
76
+ /**
77
+ * Normalizes an identifier based on the delivery method.
78
+ *
79
+ * @param identifier - The identifier to normalize
80
+ * @param method - The delivery method
81
+ * @returns Normalized identifier
82
+ */
83
+ export function normalizeIdentifier(identifier: string, method: "sms" | "email"): string {
84
+ if (method === "email") {
85
+ return normalizeEmail(identifier);
86
+ } else {
87
+ return normalizePhoneNumber(identifier);
88
+ }
89
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig",
3
+ "exclude": ["spec/**/*.ts"]
4
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es5",
4
+ "lib": ["esnext", "es2016"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "esModuleInterop": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "strict": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "module": "commonjs",
12
+ "moduleResolution": "node",
13
+ "resolveJsonModule": true,
14
+ "isolatedModules": true,
15
+ "noEmit": false,
16
+ "declaration": true,
17
+ "experimentalDecorators": true,
18
+ "checkJs": true,
19
+ "outDir": "dist",
20
+ "typeRoots": ["./node_modules/@types"]
21
+ },
22
+ "include": ["./src/*", "./spec/*"],
23
+ "exclude": ["./node_modules/*"]
24
+ }