redis-otp-manager 0.2.3 → 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,6 +15,7 @@ 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`
19
20
  - Dual package support for ESM and CommonJS consumers
20
21
 
@@ -32,7 +33,7 @@ npm install @nestjs/common @nestjs/core reflect-metadata rxjs
32
33
 
33
34
  ## Module Support
34
35
 
35
- This package now supports both:
36
+ This package supports both:
36
37
  - ESM imports
37
38
  - CommonJS/Nest `ts-node/register` style resolution
38
39
 
@@ -89,6 +90,16 @@ await otp.verify({
89
90
  });
90
91
  ```
91
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
+
92
103
  ## NestJS
93
104
 
94
105
  Import the Nest integration from the dedicated subpath so non-Nest users do not pull Nest dependencies unless they need them.
@@ -223,6 +234,7 @@ Returns `true` or throws a typed error.
223
234
  - `maxAttempts: 3`
224
235
  - `resendCooldown: 30` or higher to reduce abuse
225
236
  - keep Redis private and behind authenticated network access
237
+ - prefer `RedisAdapter` in production to get the atomic security path
226
238
 
227
239
  ## Key Design
228
240
 
@@ -237,7 +249,7 @@ cooldown:{intent}:{type}:{identifier}
237
249
 
238
250
  Publishing on every `main` merge is not recommended for npm packages because npm versions are immutable. The safer setup is:
239
251
  - merge to `main` runs CI only
240
- - 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`
241
253
 
242
254
  Required GitHub secrets:
243
255
  - `NPM_TOKEN`
@@ -245,12 +257,12 @@ Required GitHub secrets:
245
257
  Tag-based publish:
246
258
 
247
259
  ```bash
248
- git tag v0.2.0
249
- git push origin v0.2.0
260
+ git tag v0.3.0
261
+ git push origin v0.3.0
250
262
  ```
251
263
 
252
264
  ## Next Roadmap
253
265
 
254
- - atomic Redis verification
266
+ - keyed HMAC for OTP hashing
255
267
  - hooks/events
256
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
  }
@@ -1,6 +1,58 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
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
+ `;
4
56
  class RedisAdapter {
5
57
  client;
6
58
  constructor(client) {
@@ -22,5 +74,40 @@ class RedisAdapter {
22
74
  }
23
75
  return nextValue;
24
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
+ }
25
112
  }
26
113
  exports.RedisAdapter = RedisAdapter;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OTPManager = void 0;
4
+ const redis_adapter_js_1 = require("../adapters/redis.adapter.js");
4
5
  const otp_generator_js_1 = require("./otp.generator.js");
5
6
  const otp_hash_js_1 = require("./otp.hash.js");
6
7
  const otp_errors_js_1 = require("../errors/otp.errors.js");
@@ -24,6 +25,33 @@ class OTPManager {
24
25
  const attemptsKey = (0, key_builder_js_1.buildAttemptsKey)(normalizedInput);
25
26
  const rateLimitKey = (0, key_builder_js_1.buildRateLimitKey)(normalizedInput);
26
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
+ }
27
55
  if (this.options.resendCooldown) {
28
56
  const cooldownActive = await this.options.store.get(cooldownKey);
29
57
  if (cooldownActive) {
@@ -31,8 +59,6 @@ class OTPManager {
31
59
  }
32
60
  }
33
61
  await (0, rate_limiter_js_1.assertWithinRateLimit)(this.options.store, rateLimitKey, this.options.rateLimit);
34
- const otp = (0, otp_generator_js_1.generateNumericOtp)(this.options.otpLength);
35
- const hashedOtp = (0, otp_hash_js_1.hashOtp)(otp);
36
62
  await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
37
63
  await this.options.store.del(attemptsKey);
38
64
  if (this.options.resendCooldown) {
@@ -51,6 +77,27 @@ class OTPManager {
51
77
  }
52
78
  const otpKey = (0, key_builder_js_1.buildOtpKey)(normalizedInput);
53
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
+ }
54
101
  const storedHash = await this.options.store.get(otpKey);
55
102
  if (!storedHash) {
56
103
  throw new otp_errors_js_1.OTPExpiredError();
@@ -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.3",
3
+ "version": "0.3.0",
4
4
  "description": "Lightweight, Redis-backed OTP manager for Node.js apps",
5
5
  "repository": {
6
6
  "type": "git",