redis-otp-manager 0.2.2 → 0.3.0

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/README.md CHANGED
@@ -15,7 +15,9 @@ This package currently includes:
15
15
  - Rate limiting
16
16
  - Optional resend cooldown
17
17
  - Max-attempt protection
18
+ - Atomic Redis generate and verify paths using Lua scripts
18
19
  - NestJS module integration via `redis-otp-manager/nest`
20
+ - Dual package support for ESM and CommonJS consumers
19
21
 
20
22
  ## Install
21
23
 
@@ -29,6 +31,26 @@ For NestJS apps, also install the Nest peer dependencies used by your app:
29
31
  npm install @nestjs/common @nestjs/core reflect-metadata rxjs
30
32
  ```
31
33
 
34
+ ## Module Support
35
+
36
+ This package supports both:
37
+ - ESM imports
38
+ - CommonJS/Nest `ts-node/register` style resolution
39
+
40
+ ESM:
41
+
42
+ ```ts
43
+ import { OTPManager } from "redis-otp-manager";
44
+ import { OTPModule } from "redis-otp-manager/nest";
45
+ ```
46
+
47
+ CommonJS:
48
+
49
+ ```js
50
+ const { OTPManager } = require("redis-otp-manager");
51
+ const { OTPModule } = require("redis-otp-manager/nest");
52
+ ```
53
+
32
54
  ## Quality Checks
33
55
 
34
56
  ```bash
@@ -68,9 +90,19 @@ await otp.verify({
68
90
  });
69
91
  ```
70
92
 
93
+ ## Production Security Notes
94
+
95
+ When you use `RedisAdapter`, the package now takes the Redis-specific atomic path for:
96
+ - OTP generation plus rate-limit/cooldown checks
97
+ - OTP verification plus attempt tracking and OTP deletion
98
+
99
+ That prevents the most important race condition in earlier versions where two parallel correct verification requests could both succeed.
100
+
101
+ Simpler adapters like `MemoryAdapter` intentionally stay on the non-atomic fallback path to keep tests and local development lightweight.
102
+
71
103
  ## NestJS
72
104
 
73
- Import the Nest integration from the dedicated subpath so non-Nest users do not pull Nest dependencies.
105
+ Import the Nest integration from the dedicated subpath so non-Nest users do not pull Nest dependencies unless they need them.
74
106
 
75
107
  ```ts
76
108
  import { Module } from "@nestjs/common";
@@ -202,6 +234,7 @@ Returns `true` or throws a typed error.
202
234
  - `maxAttempts: 3`
203
235
  - `resendCooldown: 30` or higher to reduce abuse
204
236
  - keep Redis private and behind authenticated network access
237
+ - prefer `RedisAdapter` in production to get the atomic security path
205
238
 
206
239
  ## Key Design
207
240
 
@@ -216,7 +249,7 @@ cooldown:{intent}:{type}:{identifier}
216
249
 
217
250
  Publishing on every `main` merge is not recommended for npm packages because npm versions are immutable. The safer setup is:
218
251
  - merge to `main` runs CI only
219
- - publish happens when you push a version tag like `v0.2.0`
252
+ - publish happens when you push a version tag like `v0.3.0`
220
253
 
221
254
  Required GitHub secrets:
222
255
  - `NPM_TOKEN`
@@ -224,12 +257,12 @@ Required GitHub secrets:
224
257
  Tag-based publish:
225
258
 
226
259
  ```bash
227
- git tag v0.2.0
228
- git push origin v0.2.0
260
+ git tag v0.3.0
261
+ git push origin v0.3.0
229
262
  ```
230
263
 
231
264
  ## Next Roadmap
232
265
 
233
- - atomic Redis verification
266
+ - keyed HMAC for OTP hashing
234
267
  - hooks/events
235
268
  - analytics and observability
@@ -7,7 +7,31 @@ export interface RedisLikeClient {
7
7
  del(key: string): Promise<unknown>;
8
8
  incr(key: string): Promise<number>;
9
9
  expire(key: string, seconds: number): Promise<unknown>;
10
+ eval?(script: string, options: {
11
+ keys: string[];
12
+ arguments: string[];
13
+ }): Promise<string | number | null>;
10
14
  }
15
+ export interface AtomicGenerateParams {
16
+ otpKey: string;
17
+ attemptsKey: string;
18
+ rateLimitKey: string;
19
+ cooldownKey: string;
20
+ hashedOtp: string;
21
+ ttl: number;
22
+ rateWindow?: number;
23
+ rateMax?: number;
24
+ resendCooldown?: number;
25
+ }
26
+ export interface AtomicVerifyParams {
27
+ otpKey: string;
28
+ attemptsKey: string;
29
+ providedHash: string;
30
+ ttl: number;
31
+ maxAttempts: number;
32
+ }
33
+ export type AtomicGenerateResult = "ok" | "rate_limit" | "cooldown";
34
+ export type AtomicVerifyResult = "verified" | "expired" | "invalid" | "max_attempts";
11
35
  export declare class RedisAdapter implements StoreAdapter {
12
36
  private readonly client;
13
37
  constructor(client: RedisLikeClient);
@@ -15,4 +39,6 @@ export declare class RedisAdapter implements StoreAdapter {
15
39
  set(key: string, value: string, ttlSeconds: number): Promise<void>;
16
40
  del(key: string): Promise<void>;
17
41
  increment(key: string, ttlSeconds: number): Promise<number>;
42
+ generateOtpAtomically(params: AtomicGenerateParams): Promise<AtomicGenerateResult | null>;
43
+ verifyOtpAtomically(params: AtomicVerifyParams): Promise<AtomicVerifyResult | null>;
18
44
  }
@@ -1,3 +1,55 @@
1
+ const ATOMIC_GENERATE_SCRIPT = `
2
+ if ARGV[5] ~= '' then
3
+ local cooldownExists = redis.call('GET', KEYS[4])
4
+ if cooldownExists then
5
+ return 'cooldown'
6
+ end
7
+ end
8
+
9
+ if ARGV[3] ~= '' and ARGV[4] ~= '' then
10
+ local nextCount = redis.call('INCR', KEYS[3])
11
+ if nextCount == 1 then
12
+ redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
13
+ end
14
+ if nextCount > tonumber(ARGV[4]) then
15
+ return 'rate_limit'
16
+ end
17
+ end
18
+
19
+ redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]))
20
+ redis.call('DEL', KEYS[2])
21
+
22
+ if ARGV[5] ~= '' then
23
+ redis.call('SET', KEYS[4], '1', 'EX', tonumber(ARGV[5]))
24
+ end
25
+
26
+ return 'ok'
27
+ `;
28
+ const ATOMIC_VERIFY_SCRIPT = `
29
+ local storedHash = redis.call('GET', KEYS[1])
30
+ if not storedHash then
31
+ return 'expired'
32
+ end
33
+
34
+ if storedHash == ARGV[1] then
35
+ redis.call('DEL', KEYS[1])
36
+ redis.call('DEL', KEYS[2])
37
+ return 'verified'
38
+ end
39
+
40
+ local attempts = redis.call('INCR', KEYS[2])
41
+ if attempts == 1 then
42
+ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[2]))
43
+ end
44
+
45
+ if attempts >= tonumber(ARGV[3]) then
46
+ redis.call('DEL', KEYS[1])
47
+ redis.call('DEL', KEYS[2])
48
+ return 'max_attempts'
49
+ end
50
+
51
+ return 'invalid'
52
+ `;
1
53
  export class RedisAdapter {
2
54
  client;
3
55
  constructor(client) {
@@ -19,4 +71,39 @@ export class RedisAdapter {
19
71
  }
20
72
  return nextValue;
21
73
  }
74
+ async generateOtpAtomically(params) {
75
+ if (!this.client.eval) {
76
+ return null;
77
+ }
78
+ const result = await this.client.eval(ATOMIC_GENERATE_SCRIPT, {
79
+ keys: [params.otpKey, params.attemptsKey, params.rateLimitKey, params.cooldownKey],
80
+ arguments: [
81
+ params.hashedOtp,
82
+ String(params.ttl),
83
+ params.rateWindow ? String(params.rateWindow) : "",
84
+ params.rateMax ? String(params.rateMax) : "",
85
+ params.resendCooldown ? String(params.resendCooldown) : "",
86
+ ],
87
+ });
88
+ if (result === "ok" || result === "rate_limit" || result === "cooldown") {
89
+ return result;
90
+ }
91
+ return null;
92
+ }
93
+ async verifyOtpAtomically(params) {
94
+ if (!this.client.eval) {
95
+ return null;
96
+ }
97
+ const result = await this.client.eval(ATOMIC_VERIFY_SCRIPT, {
98
+ keys: [params.otpKey, params.attemptsKey],
99
+ arguments: [params.providedHash, String(params.ttl), String(params.maxAttempts)],
100
+ });
101
+ if (result === "verified" ||
102
+ result === "expired" ||
103
+ result === "invalid" ||
104
+ result === "max_attempts") {
105
+ return result;
106
+ }
107
+ return null;
108
+ }
22
109
  }
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemoryAdapter = void 0;
4
+ class MemoryAdapter {
5
+ storage = new Map();
6
+ async get(key) {
7
+ const record = this.storage.get(key);
8
+ if (!record) {
9
+ return null;
10
+ }
11
+ if (record.expiresAt <= Date.now()) {
12
+ this.storage.delete(key);
13
+ return null;
14
+ }
15
+ return record.value;
16
+ }
17
+ async set(key, value, ttlSeconds) {
18
+ this.storage.set(key, {
19
+ value,
20
+ expiresAt: Date.now() + ttlSeconds * 1000,
21
+ });
22
+ }
23
+ async del(key) {
24
+ this.storage.delete(key);
25
+ }
26
+ async increment(key, ttlSeconds) {
27
+ const currentRecord = this.storage.get(key);
28
+ if (!currentRecord || currentRecord.expiresAt <= Date.now()) {
29
+ await this.set(key, "1", ttlSeconds);
30
+ return 1;
31
+ }
32
+ const nextValue = Number(currentRecord.value) + 1;
33
+ this.storage.set(key, {
34
+ value: String(nextValue),
35
+ expiresAt: currentRecord.expiresAt,
36
+ });
37
+ return nextValue;
38
+ }
39
+ }
40
+ exports.MemoryAdapter = MemoryAdapter;
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RedisAdapter = void 0;
4
+ const ATOMIC_GENERATE_SCRIPT = `
5
+ if ARGV[5] ~= '' then
6
+ local cooldownExists = redis.call('GET', KEYS[4])
7
+ if cooldownExists then
8
+ return 'cooldown'
9
+ end
10
+ end
11
+
12
+ if ARGV[3] ~= '' and ARGV[4] ~= '' then
13
+ local nextCount = redis.call('INCR', KEYS[3])
14
+ if nextCount == 1 then
15
+ redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3]))
16
+ end
17
+ if nextCount > tonumber(ARGV[4]) then
18
+ return 'rate_limit'
19
+ end
20
+ end
21
+
22
+ redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]))
23
+ redis.call('DEL', KEYS[2])
24
+
25
+ if ARGV[5] ~= '' then
26
+ redis.call('SET', KEYS[4], '1', 'EX', tonumber(ARGV[5]))
27
+ end
28
+
29
+ return 'ok'
30
+ `;
31
+ const ATOMIC_VERIFY_SCRIPT = `
32
+ local storedHash = redis.call('GET', KEYS[1])
33
+ if not storedHash then
34
+ return 'expired'
35
+ end
36
+
37
+ if storedHash == ARGV[1] then
38
+ redis.call('DEL', KEYS[1])
39
+ redis.call('DEL', KEYS[2])
40
+ return 'verified'
41
+ end
42
+
43
+ local attempts = redis.call('INCR', KEYS[2])
44
+ if attempts == 1 then
45
+ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[2]))
46
+ end
47
+
48
+ if attempts >= tonumber(ARGV[3]) then
49
+ redis.call('DEL', KEYS[1])
50
+ redis.call('DEL', KEYS[2])
51
+ return 'max_attempts'
52
+ end
53
+
54
+ return 'invalid'
55
+ `;
56
+ class RedisAdapter {
57
+ client;
58
+ constructor(client) {
59
+ this.client = client;
60
+ }
61
+ async get(key) {
62
+ return this.client.get(key);
63
+ }
64
+ async set(key, value, ttlSeconds) {
65
+ await this.client.set(key, value, { EX: ttlSeconds });
66
+ }
67
+ async del(key) {
68
+ await this.client.del(key);
69
+ }
70
+ async increment(key, ttlSeconds) {
71
+ const nextValue = await this.client.incr(key);
72
+ if (nextValue === 1) {
73
+ await this.client.expire(key, ttlSeconds);
74
+ }
75
+ return nextValue;
76
+ }
77
+ async generateOtpAtomically(params) {
78
+ if (!this.client.eval) {
79
+ return null;
80
+ }
81
+ const result = await this.client.eval(ATOMIC_GENERATE_SCRIPT, {
82
+ keys: [params.otpKey, params.attemptsKey, params.rateLimitKey, params.cooldownKey],
83
+ arguments: [
84
+ params.hashedOtp,
85
+ String(params.ttl),
86
+ params.rateWindow ? String(params.rateWindow) : "",
87
+ params.rateMax ? String(params.rateMax) : "",
88
+ params.resendCooldown ? String(params.resendCooldown) : "",
89
+ ],
90
+ });
91
+ if (result === "ok" || result === "rate_limit" || result === "cooldown") {
92
+ return result;
93
+ }
94
+ return null;
95
+ }
96
+ async verifyOtpAtomically(params) {
97
+ if (!this.client.eval) {
98
+ return null;
99
+ }
100
+ const result = await this.client.eval(ATOMIC_VERIFY_SCRIPT, {
101
+ keys: [params.otpKey, params.attemptsKey],
102
+ arguments: [params.providedHash, String(params.ttl), String(params.maxAttempts)],
103
+ });
104
+ if (result === "verified" ||
105
+ result === "expired" ||
106
+ result === "invalid" ||
107
+ result === "max_attempts") {
108
+ return result;
109
+ }
110
+ return null;
111
+ }
112
+ }
113
+ exports.RedisAdapter = RedisAdapter;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.generateNumericOtp = generateNumericOtp;
4
+ const node_crypto_1 = require("node:crypto");
5
+ function generateNumericOtp(length = 6) {
6
+ if (!Number.isInteger(length) || length <= 0) {
7
+ throw new TypeError("OTP length must be a positive integer.");
8
+ }
9
+ let otp = "";
10
+ for (let index = 0; index < length; index += 1) {
11
+ otp += (0, node_crypto_1.randomInt)(0, 10).toString();
12
+ }
13
+ return otp;
14
+ }
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hashOtp = hashOtp;
4
+ exports.verifyOtpHash = verifyOtpHash;
5
+ const node_crypto_1 = require("node:crypto");
6
+ function hashOtp(otp) {
7
+ return (0, node_crypto_1.createHash)("sha256").update(otp).digest("hex");
8
+ }
9
+ function verifyOtpHash(otp, expectedHash) {
10
+ const actualBuffer = Buffer.from(hashOtp(otp), "hex");
11
+ const expectedBuffer = Buffer.from(expectedHash, "hex");
12
+ if (actualBuffer.length !== expectedBuffer.length) {
13
+ return false;
14
+ }
15
+ return (0, node_crypto_1.timingSafeEqual)(actualBuffer, expectedBuffer);
16
+ }
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OTPManager = void 0;
4
+ const redis_adapter_js_1 = require("../adapters/redis.adapter.js");
5
+ const otp_generator_js_1 = require("./otp.generator.js");
6
+ const otp_hash_js_1 = require("./otp.hash.js");
7
+ const otp_errors_js_1 = require("../errors/otp.errors.js");
8
+ const key_builder_js_1 = require("../utils/key-builder.js");
9
+ const identifier_normalizer_js_1 = require("../utils/identifier-normalizer.js");
10
+ const rate_limiter_js_1 = require("../utils/rate-limiter.js");
11
+ class OTPManager {
12
+ options;
13
+ constructor(options) {
14
+ validateManagerOptions(options);
15
+ this.options = {
16
+ ...options,
17
+ otpLength: options.otpLength ?? 6,
18
+ devMode: options.devMode ?? false,
19
+ };
20
+ }
21
+ async generate(input) {
22
+ validatePayload(input);
23
+ const normalizedInput = (0, identifier_normalizer_js_1.normalizePayloadIdentifier)(input, this.options.identifierNormalization);
24
+ const otpKey = (0, key_builder_js_1.buildOtpKey)(normalizedInput);
25
+ const attemptsKey = (0, key_builder_js_1.buildAttemptsKey)(normalizedInput);
26
+ const rateLimitKey = (0, key_builder_js_1.buildRateLimitKey)(normalizedInput);
27
+ const cooldownKey = (0, key_builder_js_1.buildCooldownKey)(normalizedInput);
28
+ const otp = (0, otp_generator_js_1.generateNumericOtp)(this.options.otpLength);
29
+ const hashedOtp = (0, otp_hash_js_1.hashOtp)(otp);
30
+ if (this.options.store instanceof redis_adapter_js_1.RedisAdapter) {
31
+ const atomicResult = await this.options.store.generateOtpAtomically({
32
+ otpKey,
33
+ attemptsKey,
34
+ rateLimitKey,
35
+ cooldownKey,
36
+ hashedOtp,
37
+ ttl: this.options.ttl,
38
+ rateWindow: this.options.rateLimit?.window,
39
+ rateMax: this.options.rateLimit?.max,
40
+ resendCooldown: this.options.resendCooldown,
41
+ });
42
+ if (atomicResult === "cooldown") {
43
+ throw new otp_errors_js_1.OTPResendCooldownError();
44
+ }
45
+ if (atomicResult === "rate_limit") {
46
+ throw new otp_errors_js_1.OTPRateLimitExceededError();
47
+ }
48
+ if (atomicResult === "ok") {
49
+ return {
50
+ expiresIn: this.options.ttl,
51
+ otp: this.options.devMode ? otp : undefined,
52
+ };
53
+ }
54
+ }
55
+ if (this.options.resendCooldown) {
56
+ const cooldownActive = await this.options.store.get(cooldownKey);
57
+ if (cooldownActive) {
58
+ throw new otp_errors_js_1.OTPResendCooldownError();
59
+ }
60
+ }
61
+ await (0, rate_limiter_js_1.assertWithinRateLimit)(this.options.store, rateLimitKey, this.options.rateLimit);
62
+ await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
63
+ await this.options.store.del(attemptsKey);
64
+ if (this.options.resendCooldown) {
65
+ await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
66
+ }
67
+ return {
68
+ expiresIn: this.options.ttl,
69
+ otp: this.options.devMode ? otp : undefined,
70
+ };
71
+ }
72
+ async verify(input) {
73
+ validatePayload(input);
74
+ const normalizedInput = (0, identifier_normalizer_js_1.normalizePayloadIdentifier)(input, this.options.identifierNormalization);
75
+ if (!input.otp || !input.otp.trim()) {
76
+ throw new TypeError("OTP must be a non-empty string.");
77
+ }
78
+ const otpKey = (0, key_builder_js_1.buildOtpKey)(normalizedInput);
79
+ const attemptsKey = (0, key_builder_js_1.buildAttemptsKey)(normalizedInput);
80
+ if (this.options.store instanceof redis_adapter_js_1.RedisAdapter) {
81
+ const atomicResult = await this.options.store.verifyOtpAtomically({
82
+ otpKey,
83
+ attemptsKey,
84
+ providedHash: (0, otp_hash_js_1.hashOtp)(input.otp),
85
+ ttl: this.options.ttl,
86
+ maxAttempts: this.options.maxAttempts,
87
+ });
88
+ if (atomicResult === "verified") {
89
+ return true;
90
+ }
91
+ if (atomicResult === "expired") {
92
+ throw new otp_errors_js_1.OTPExpiredError();
93
+ }
94
+ if (atomicResult === "max_attempts") {
95
+ throw new otp_errors_js_1.OTPMaxAttemptsExceededError();
96
+ }
97
+ if (atomicResult === "invalid") {
98
+ throw new otp_errors_js_1.OTPInvalidError();
99
+ }
100
+ }
101
+ const storedHash = await this.options.store.get(otpKey);
102
+ if (!storedHash) {
103
+ throw new otp_errors_js_1.OTPExpiredError();
104
+ }
105
+ const isValid = (0, otp_hash_js_1.verifyOtpHash)(input.otp, storedHash);
106
+ if (!isValid) {
107
+ const attempts = await this.options.store.increment(attemptsKey, this.options.ttl);
108
+ if (attempts >= this.options.maxAttempts) {
109
+ await this.options.store.del(otpKey);
110
+ await this.options.store.del(attemptsKey);
111
+ throw new otp_errors_js_1.OTPMaxAttemptsExceededError();
112
+ }
113
+ throw new otp_errors_js_1.OTPInvalidError();
114
+ }
115
+ await this.options.store.del(otpKey);
116
+ await this.options.store.del(attemptsKey);
117
+ return true;
118
+ }
119
+ }
120
+ exports.OTPManager = OTPManager;
121
+ function validateManagerOptions(options) {
122
+ if (!Number.isInteger(options.ttl) || options.ttl <= 0) {
123
+ throw new TypeError("ttl must be a positive integer.");
124
+ }
125
+ if (!Number.isInteger(options.maxAttempts) || options.maxAttempts <= 0) {
126
+ throw new TypeError("maxAttempts must be a positive integer.");
127
+ }
128
+ if (options.otpLength !== undefined &&
129
+ (!Number.isInteger(options.otpLength) || options.otpLength <= 0)) {
130
+ throw new TypeError("otpLength must be a positive integer when provided.");
131
+ }
132
+ if (options.resendCooldown !== undefined &&
133
+ (!Number.isInteger(options.resendCooldown) || options.resendCooldown <= 0)) {
134
+ throw new TypeError("resendCooldown must be a positive integer when provided.");
135
+ }
136
+ if (options.rateLimit) {
137
+ if (!Number.isInteger(options.rateLimit.window) || options.rateLimit.window <= 0) {
138
+ throw new TypeError("rateLimit.window must be a positive integer.");
139
+ }
140
+ if (!Number.isInteger(options.rateLimit.max) || options.rateLimit.max <= 0) {
141
+ throw new TypeError("rateLimit.max must be a positive integer.");
142
+ }
143
+ }
144
+ if (options.identifierNormalization) {
145
+ const { trim, lowercase, preserveCaseFor } = options.identifierNormalization;
146
+ if (trim !== undefined && typeof trim !== "boolean") {
147
+ throw new TypeError("identifierNormalization.trim must be a boolean.");
148
+ }
149
+ if (lowercase !== undefined && typeof lowercase !== "boolean") {
150
+ throw new TypeError("identifierNormalization.lowercase must be a boolean.");
151
+ }
152
+ if (preserveCaseFor !== undefined &&
153
+ (!Array.isArray(preserveCaseFor) ||
154
+ preserveCaseFor.some((channel) => typeof channel !== "string" || !channel.trim()))) {
155
+ throw new TypeError("identifierNormalization.preserveCaseFor must be an array of non-empty strings.");
156
+ }
157
+ }
158
+ }
159
+ function validatePayload(input) {
160
+ if (!input.type || !input.type.trim()) {
161
+ throw new TypeError("type must be a non-empty string.");
162
+ }
163
+ if (!input.identifier || !input.identifier.trim()) {
164
+ throw new TypeError("identifier must be a non-empty string.");
165
+ }
166
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OTPResendCooldownError = exports.OTPMaxAttemptsExceededError = exports.OTPInvalidError = exports.OTPExpiredError = exports.OTPRateLimitExceededError = exports.OTPError = void 0;
4
+ class OTPError extends Error {
5
+ code;
6
+ constructor(message, code) {
7
+ super(message);
8
+ this.name = new.target.name;
9
+ this.code = code;
10
+ }
11
+ }
12
+ exports.OTPError = OTPError;
13
+ class OTPRateLimitExceededError extends OTPError {
14
+ constructor(message = "OTP request rate limit exceeded.") {
15
+ super(message, "OTP_RATE_LIMIT_EXCEEDED");
16
+ }
17
+ }
18
+ exports.OTPRateLimitExceededError = OTPRateLimitExceededError;
19
+ class OTPExpiredError extends OTPError {
20
+ constructor(message = "OTP has expired or does not exist.") {
21
+ super(message, "OTP_EXPIRED");
22
+ }
23
+ }
24
+ exports.OTPExpiredError = OTPExpiredError;
25
+ class OTPInvalidError extends OTPError {
26
+ constructor(message = "OTP is invalid.") {
27
+ super(message, "OTP_INVALID");
28
+ }
29
+ }
30
+ exports.OTPInvalidError = OTPInvalidError;
31
+ class OTPMaxAttemptsExceededError extends OTPError {
32
+ constructor(message = "Maximum OTP verification attempts exceeded.") {
33
+ super(message, "OTP_MAX_ATTEMPTS_EXCEEDED");
34
+ }
35
+ }
36
+ exports.OTPMaxAttemptsExceededError = OTPMaxAttemptsExceededError;
37
+ class OTPResendCooldownError extends OTPError {
38
+ constructor(message = "OTP resend cooldown is active.") {
39
+ super(message, "OTP_RESEND_COOLDOWN");
40
+ }
41
+ }
42
+ exports.OTPResendCooldownError = OTPResendCooldownError;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OTPResendCooldownError = exports.OTPRateLimitExceededError = exports.OTPMaxAttemptsExceededError = exports.OTPInvalidError = exports.OTPExpiredError = exports.OTPError = exports.MemoryAdapter = exports.RedisAdapter = exports.OTPManager = void 0;
4
+ var otp_service_js_1 = require("./core/otp.service.js");
5
+ Object.defineProperty(exports, "OTPManager", { enumerable: true, get: function () { return otp_service_js_1.OTPManager; } });
6
+ var redis_adapter_js_1 = require("./adapters/redis.adapter.js");
7
+ Object.defineProperty(exports, "RedisAdapter", { enumerable: true, get: function () { return redis_adapter_js_1.RedisAdapter; } });
8
+ var memory_adapter_js_1 = require("./adapters/memory.adapter.js");
9
+ Object.defineProperty(exports, "MemoryAdapter", { enumerable: true, get: function () { return memory_adapter_js_1.MemoryAdapter; } });
10
+ var otp_errors_js_1 = require("./errors/otp.errors.js");
11
+ Object.defineProperty(exports, "OTPError", { enumerable: true, get: function () { return otp_errors_js_1.OTPError; } });
12
+ Object.defineProperty(exports, "OTPExpiredError", { enumerable: true, get: function () { return otp_errors_js_1.OTPExpiredError; } });
13
+ Object.defineProperty(exports, "OTPInvalidError", { enumerable: true, get: function () { return otp_errors_js_1.OTPInvalidError; } });
14
+ Object.defineProperty(exports, "OTPMaxAttemptsExceededError", { enumerable: true, get: function () { return otp_errors_js_1.OTPMaxAttemptsExceededError; } });
15
+ Object.defineProperty(exports, "OTPRateLimitExceededError", { enumerable: true, get: function () { return otp_errors_js_1.OTPRateLimitExceededError; } });
16
+ Object.defineProperty(exports, "OTPResendCooldownError", { enumerable: true, get: function () { return otp_errors_js_1.OTPResendCooldownError; } });
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InjectOTPManager = exports.OTP_MANAGER_OPTIONS = exports.OTPModule = void 0;
4
+ var otp_module_js_1 = require("./otp.module.js");
5
+ Object.defineProperty(exports, "OTPModule", { enumerable: true, get: function () { return otp_module_js_1.OTPModule; } });
6
+ Object.defineProperty(exports, "OTP_MANAGER_OPTIONS", { enumerable: true, get: function () { return otp_module_js_1.OTP_MANAGER_OPTIONS; } });
7
+ var otp_decorator_js_1 = require("./otp.decorator.js");
8
+ Object.defineProperty(exports, "InjectOTPManager", { enumerable: true, get: function () { return otp_decorator_js_1.InjectOTPManager; } });
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InjectOTPManager = InjectOTPManager;
4
+ const common_1 = require("@nestjs/common");
5
+ const otp_service_js_1 = require("../../core/otp.service.js");
6
+ function InjectOTPManager() {
7
+ return (0, common_1.Inject)(otp_service_js_1.OTPManager);
8
+ }
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
3
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
4
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
5
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
6
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
7
+ var _, done = false;
8
+ for (var i = decorators.length - 1; i >= 0; i--) {
9
+ var context = {};
10
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
11
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
12
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
13
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
14
+ if (kind === "accessor") {
15
+ if (result === void 0) continue;
16
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
17
+ if (_ = accept(result.get)) descriptor.get = _;
18
+ if (_ = accept(result.set)) descriptor.set = _;
19
+ if (_ = accept(result.init)) initializers.unshift(_);
20
+ }
21
+ else if (_ = accept(result)) {
22
+ if (kind === "field") initializers.unshift(_);
23
+ else descriptor[key] = _;
24
+ }
25
+ }
26
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
27
+ done = true;
28
+ };
29
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
30
+ var useValue = arguments.length > 2;
31
+ for (var i = 0; i < initializers.length; i++) {
32
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
33
+ }
34
+ return useValue ? value : void 0;
35
+ };
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.OTPModule = exports.OTP_MANAGER_OPTIONS = void 0;
38
+ const common_1 = require("@nestjs/common");
39
+ const otp_service_js_1 = require("../../core/otp.service.js");
40
+ exports.OTP_MANAGER_OPTIONS = Symbol("OTP_MANAGER_OPTIONS");
41
+ let OTPModule = (() => {
42
+ let _classDecorators = [(0, common_1.Module)({})];
43
+ let _classDescriptor;
44
+ let _classExtraInitializers = [];
45
+ let _classThis;
46
+ var OTPModule = class {
47
+ static { _classThis = this; }
48
+ static {
49
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
50
+ __esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
51
+ OTPModule = _classThis = _classDescriptor.value;
52
+ if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
53
+ __runInitializers(_classThis, _classExtraInitializers);
54
+ }
55
+ static forRoot(options) {
56
+ const managerProvider = createManagerProvider(options);
57
+ return {
58
+ module: OTPModule,
59
+ global: options.isGlobal ?? false,
60
+ providers: [managerProvider],
61
+ exports: [otp_service_js_1.OTPManager],
62
+ };
63
+ }
64
+ static forRootAsync(options) {
65
+ const optionsProvider = {
66
+ provide: exports.OTP_MANAGER_OPTIONS,
67
+ inject: options.inject ?? [],
68
+ useFactory: options.useFactory,
69
+ };
70
+ const managerProvider = {
71
+ provide: otp_service_js_1.OTPManager,
72
+ inject: [exports.OTP_MANAGER_OPTIONS],
73
+ useFactory: (resolvedOptions) => new otp_service_js_1.OTPManager(resolvedOptions),
74
+ };
75
+ return {
76
+ module: OTPModule,
77
+ global: options.isGlobal ?? false,
78
+ imports: options.imports ?? [],
79
+ providers: [optionsProvider, managerProvider],
80
+ exports: [otp_service_js_1.OTPManager],
81
+ };
82
+ }
83
+ };
84
+ return OTPModule = _classThis;
85
+ })();
86
+ exports.OTPModule = OTPModule;
87
+ function createManagerProvider(options) {
88
+ return {
89
+ provide: otp_service_js_1.OTPManager,
90
+ useValue: new otp_service_js_1.OTPManager(options),
91
+ };
92
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizePayloadIdentifier = normalizePayloadIdentifier;
4
+ const DEFAULT_IDENTIFIER_NORMALIZATION = {
5
+ trim: true,
6
+ lowercase: true,
7
+ preserveCaseFor: ["sms", "token"],
8
+ };
9
+ function normalizePayloadIdentifier(payload, config) {
10
+ const resolvedConfig = resolveNormalizationConfig(config);
11
+ let identifier = payload.identifier;
12
+ if (resolvedConfig.trim) {
13
+ identifier = identifier.trim();
14
+ }
15
+ const preserveCase = resolvedConfig.preserveCaseFor.includes(payload.type);
16
+ if (resolvedConfig.lowercase && !preserveCase) {
17
+ identifier = identifier.toLowerCase();
18
+ }
19
+ return {
20
+ ...payload,
21
+ identifier,
22
+ };
23
+ }
24
+ function resolveNormalizationConfig(config) {
25
+ return {
26
+ ...DEFAULT_IDENTIFIER_NORMALIZATION,
27
+ ...config,
28
+ preserveCaseFor: config?.preserveCaseFor ?? DEFAULT_IDENTIFIER_NORMALIZATION.preserveCaseFor,
29
+ };
30
+ }
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildOtpKey = buildOtpKey;
4
+ exports.buildAttemptsKey = buildAttemptsKey;
5
+ exports.buildRateLimitKey = buildRateLimitKey;
6
+ exports.buildCooldownKey = buildCooldownKey;
7
+ function normalizeIntent(intent) {
8
+ return intent ?? "default";
9
+ }
10
+ function buildOtpKey(payload) {
11
+ return `otp:${normalizeIntent(payload.intent)}:${payload.type}:${payload.identifier}`;
12
+ }
13
+ function buildAttemptsKey(payload) {
14
+ return `attempts:${normalizeIntent(payload.intent)}:${payload.type}:${payload.identifier}`;
15
+ }
16
+ function buildRateLimitKey(payload) {
17
+ return `rate:${payload.type}:${payload.identifier}`;
18
+ }
19
+ function buildCooldownKey(payload) {
20
+ return `cooldown:${normalizeIntent(payload.intent)}:${payload.type}:${payload.identifier}`;
21
+ }
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertWithinRateLimit = assertWithinRateLimit;
4
+ const otp_errors_js_1 = require("../errors/otp.errors.js");
5
+ async function assertWithinRateLimit(store, key, config) {
6
+ if (!config) {
7
+ return;
8
+ }
9
+ const nextCount = await store.increment(key, config.window);
10
+ if (nextCount > config.max) {
11
+ throw new otp_errors_js_1.OTPRateLimitExceededError();
12
+ }
13
+ }
@@ -1,6 +1,7 @@
1
+ import { RedisAdapter } from "../adapters/redis.adapter.js";
1
2
  import { generateNumericOtp } from "./otp.generator.js";
2
3
  import { hashOtp, verifyOtpHash } from "./otp.hash.js";
3
- import { OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPResendCooldownError, } from "../errors/otp.errors.js";
4
+ import { OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, OTPResendCooldownError, } from "../errors/otp.errors.js";
4
5
  import { buildAttemptsKey, buildCooldownKey, buildOtpKey, buildRateLimitKey, } from "../utils/key-builder.js";
5
6
  import { normalizePayloadIdentifier } from "../utils/identifier-normalizer.js";
6
7
  import { assertWithinRateLimit } from "../utils/rate-limiter.js";
@@ -21,6 +22,33 @@ export class OTPManager {
21
22
  const attemptsKey = buildAttemptsKey(normalizedInput);
22
23
  const rateLimitKey = buildRateLimitKey(normalizedInput);
23
24
  const cooldownKey = buildCooldownKey(normalizedInput);
25
+ const otp = generateNumericOtp(this.options.otpLength);
26
+ const hashedOtp = hashOtp(otp);
27
+ if (this.options.store instanceof RedisAdapter) {
28
+ const atomicResult = await this.options.store.generateOtpAtomically({
29
+ otpKey,
30
+ attemptsKey,
31
+ rateLimitKey,
32
+ cooldownKey,
33
+ hashedOtp,
34
+ ttl: this.options.ttl,
35
+ rateWindow: this.options.rateLimit?.window,
36
+ rateMax: this.options.rateLimit?.max,
37
+ resendCooldown: this.options.resendCooldown,
38
+ });
39
+ if (atomicResult === "cooldown") {
40
+ throw new OTPResendCooldownError();
41
+ }
42
+ if (atomicResult === "rate_limit") {
43
+ throw new OTPRateLimitExceededError();
44
+ }
45
+ if (atomicResult === "ok") {
46
+ return {
47
+ expiresIn: this.options.ttl,
48
+ otp: this.options.devMode ? otp : undefined,
49
+ };
50
+ }
51
+ }
24
52
  if (this.options.resendCooldown) {
25
53
  const cooldownActive = await this.options.store.get(cooldownKey);
26
54
  if (cooldownActive) {
@@ -28,8 +56,6 @@ export class OTPManager {
28
56
  }
29
57
  }
30
58
  await assertWithinRateLimit(this.options.store, rateLimitKey, this.options.rateLimit);
31
- const otp = generateNumericOtp(this.options.otpLength);
32
- const hashedOtp = hashOtp(otp);
33
59
  await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
34
60
  await this.options.store.del(attemptsKey);
35
61
  if (this.options.resendCooldown) {
@@ -48,6 +74,27 @@ export class OTPManager {
48
74
  }
49
75
  const otpKey = buildOtpKey(normalizedInput);
50
76
  const attemptsKey = buildAttemptsKey(normalizedInput);
77
+ if (this.options.store instanceof RedisAdapter) {
78
+ const atomicResult = await this.options.store.verifyOtpAtomically({
79
+ otpKey,
80
+ attemptsKey,
81
+ providedHash: hashOtp(input.otp),
82
+ ttl: this.options.ttl,
83
+ maxAttempts: this.options.maxAttempts,
84
+ });
85
+ if (atomicResult === "verified") {
86
+ return true;
87
+ }
88
+ if (atomicResult === "expired") {
89
+ throw new OTPExpiredError();
90
+ }
91
+ if (atomicResult === "max_attempts") {
92
+ throw new OTPMaxAttemptsExceededError();
93
+ }
94
+ if (atomicResult === "invalid") {
95
+ throw new OTPInvalidError();
96
+ }
97
+ }
51
98
  const storedHash = await this.options.store.get(otpKey);
52
99
  if (!storedHash) {
53
100
  throw new OTPExpiredError();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redis-otp-manager",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Lightweight, Redis-backed OTP manager for Node.js apps",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,16 +13,20 @@
13
13
  "license": "MIT",
14
14
  "author": "Prakash",
15
15
  "type": "module",
16
- "main": "./dist/index.js",
16
+ "main": "./dist/cjs/index.js",
17
17
  "types": "./dist/index.d.ts",
18
18
  "exports": {
19
19
  ".": {
20
20
  "types": "./dist/index.d.ts",
21
- "import": "./dist/index.js"
21
+ "import": "./dist/index.js",
22
+ "require": "./dist/cjs/index.js",
23
+ "default": "./dist/index.js"
22
24
  },
23
25
  "./nest": {
24
26
  "types": "./dist/integrations/nest/index.d.ts",
25
- "import": "./dist/integrations/nest/index.js"
27
+ "import": "./dist/integrations/nest/index.js",
28
+ "require": "./dist/cjs/integrations/nest/index.js",
29
+ "default": "./dist/integrations/nest/index.js"
26
30
  }
27
31
  },
28
32
  "files": [
@@ -32,7 +36,7 @@
32
36
  ],
33
37
  "scripts": {
34
38
  "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
35
- "build": "npm run clean && tsc -p tsconfig.json",
39
+ "build": "npm run clean && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/prepare-cjs.cjs",
36
40
  "test": "tsx --test test/**/*.test.ts",
37
41
  "check": "npm run build && npm run test",
38
42
  "prepublishOnly": "npm run build"