redis-otp-manager 0.2.1 → 0.2.2

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
@@ -11,7 +11,9 @@ This package currently includes:
11
11
  - Redis-compatible storage adapter
12
12
  - In-memory adapter for tests
13
13
  - Intent-aware key strategy
14
+ - Conservative identifier normalization
14
15
  - Rate limiting
16
+ - Optional resend cooldown
15
17
  - Max-attempt protection
16
18
  - NestJS module integration via `redis-otp-manager/nest`
17
19
 
@@ -44,11 +46,12 @@ const otp = new OTPManager({
44
46
  store: new RedisAdapter(redisClient),
45
47
  ttl: 300,
46
48
  maxAttempts: 5,
49
+ resendCooldown: 45,
47
50
  rateLimit: {
48
51
  window: 60,
49
52
  max: 3,
50
53
  },
51
- devMode: process.env.NODE_ENV !== "production",
54
+ devMode: false,
52
55
  });
53
56
 
54
57
  const generated = await otp.generate({
@@ -83,6 +86,7 @@ const redisClient = createClient({ url: process.env.REDIS_URL });
83
86
  store: new RedisAdapter(redisClient),
84
87
  ttl: 300,
85
88
  maxAttempts: 5,
89
+ resendCooldown: 45,
86
90
  rateLimit: {
87
91
  window: 60,
88
92
  max: 3,
@@ -104,6 +108,7 @@ OTPModule.forRootAsync({
104
108
  store: new RedisAdapter(createClient({ url: config.getOrThrow("REDIS_URL") })),
105
109
  ttl: 300,
106
110
  maxAttempts: 5,
111
+ resendCooldown: 45,
107
112
  }),
108
113
  });
109
114
  ```
@@ -130,15 +135,26 @@ type OTPManagerOptions = {
130
135
  store: StoreAdapter;
131
136
  ttl: number;
132
137
  maxAttempts: number;
138
+ resendCooldown?: number;
133
139
  rateLimit?: {
134
140
  window: number;
135
141
  max: number;
136
142
  };
137
143
  devMode?: boolean;
138
144
  otpLength?: number;
145
+ identifierNormalization?: {
146
+ trim?: boolean;
147
+ lowercase?: boolean;
148
+ preserveCaseFor?: string[];
149
+ };
139
150
  };
140
151
  ```
141
152
 
153
+ Normalization is backward-compatible and conservative by default:
154
+ - identifiers are trimmed
155
+ - identifiers are lowercased for most channels
156
+ - `sms` and `token` preserve case by default
157
+
142
158
  ### `generate(input)`
143
159
 
144
160
  ```ts
@@ -177,6 +193,15 @@ Returns `true` or throws a typed error.
177
193
  - `OTPExpiredError`
178
194
  - `OTPInvalidError`
179
195
  - `OTPMaxAttemptsExceededError`
196
+ - `OTPResendCooldownError`
197
+
198
+ ## Safer Production Defaults
199
+
200
+ - `devMode: false`
201
+ - `otpLength: 8` for higher-risk flows
202
+ - `maxAttempts: 3`
203
+ - `resendCooldown: 30` or higher to reduce abuse
204
+ - keep Redis private and behind authenticated network access
180
205
 
181
206
  ## Key Design
182
207
 
@@ -184,6 +209,7 @@ Returns `true` or throws a typed error.
184
209
  otp:{intent}:{type}:{identifier}
185
210
  attempts:{intent}:{type}:{identifier}
186
211
  rate:{type}:{identifier}
212
+ cooldown:{intent}:{type}:{identifier}
187
213
  ```
188
214
 
189
215
  ## Release Automation
@@ -204,6 +230,6 @@ git push origin v0.2.0
204
230
 
205
231
  ## Next Roadmap
206
232
 
207
- - token/email helpers
233
+ - atomic Redis verification
208
234
  - hooks/events
209
235
  - analytics and observability
@@ -1,7 +1,8 @@
1
1
  import { generateNumericOtp } from "./otp.generator.js";
2
2
  import { hashOtp, verifyOtpHash } from "./otp.hash.js";
3
- import { OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, } from "../errors/otp.errors.js";
4
- import { buildAttemptsKey, buildOtpKey, buildRateLimitKey, } from "../utils/key-builder.js";
3
+ import { OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPResendCooldownError, } from "../errors/otp.errors.js";
4
+ import { buildAttemptsKey, buildCooldownKey, buildOtpKey, buildRateLimitKey, } from "../utils/key-builder.js";
5
+ import { normalizePayloadIdentifier } from "../utils/identifier-normalizer.js";
5
6
  import { assertWithinRateLimit } from "../utils/rate-limiter.js";
6
7
  export class OTPManager {
7
8
  options;
@@ -15,14 +16,25 @@ export class OTPManager {
15
16
  }
16
17
  async generate(input) {
17
18
  validatePayload(input);
18
- const otpKey = buildOtpKey(input);
19
- const attemptsKey = buildAttemptsKey(input);
20
- const rateLimitKey = buildRateLimitKey(input);
19
+ const normalizedInput = normalizePayloadIdentifier(input, this.options.identifierNormalization);
20
+ const otpKey = buildOtpKey(normalizedInput);
21
+ const attemptsKey = buildAttemptsKey(normalizedInput);
22
+ const rateLimitKey = buildRateLimitKey(normalizedInput);
23
+ const cooldownKey = buildCooldownKey(normalizedInput);
24
+ if (this.options.resendCooldown) {
25
+ const cooldownActive = await this.options.store.get(cooldownKey);
26
+ if (cooldownActive) {
27
+ throw new OTPResendCooldownError();
28
+ }
29
+ }
21
30
  await assertWithinRateLimit(this.options.store, rateLimitKey, this.options.rateLimit);
22
31
  const otp = generateNumericOtp(this.options.otpLength);
23
32
  const hashedOtp = hashOtp(otp);
24
33
  await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
25
34
  await this.options.store.del(attemptsKey);
35
+ if (this.options.resendCooldown) {
36
+ await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
37
+ }
26
38
  return {
27
39
  expiresIn: this.options.ttl,
28
40
  otp: this.options.devMode ? otp : undefined,
@@ -30,11 +42,12 @@ export class OTPManager {
30
42
  }
31
43
  async verify(input) {
32
44
  validatePayload(input);
45
+ const normalizedInput = normalizePayloadIdentifier(input, this.options.identifierNormalization);
33
46
  if (!input.otp || !input.otp.trim()) {
34
47
  throw new TypeError("OTP must be a non-empty string.");
35
48
  }
36
- const otpKey = buildOtpKey(input);
37
- const attemptsKey = buildAttemptsKey(input);
49
+ const otpKey = buildOtpKey(normalizedInput);
50
+ const attemptsKey = buildAttemptsKey(normalizedInput);
38
51
  const storedHash = await this.options.store.get(otpKey);
39
52
  if (!storedHash) {
40
53
  throw new OTPExpiredError();
@@ -65,6 +78,10 @@ function validateManagerOptions(options) {
65
78
  (!Number.isInteger(options.otpLength) || options.otpLength <= 0)) {
66
79
  throw new TypeError("otpLength must be a positive integer when provided.");
67
80
  }
81
+ if (options.resendCooldown !== undefined &&
82
+ (!Number.isInteger(options.resendCooldown) || options.resendCooldown <= 0)) {
83
+ throw new TypeError("resendCooldown must be a positive integer when provided.");
84
+ }
68
85
  if (options.rateLimit) {
69
86
  if (!Number.isInteger(options.rateLimit.window) || options.rateLimit.window <= 0) {
70
87
  throw new TypeError("rateLimit.window must be a positive integer.");
@@ -73,6 +90,20 @@ function validateManagerOptions(options) {
73
90
  throw new TypeError("rateLimit.max must be a positive integer.");
74
91
  }
75
92
  }
93
+ if (options.identifierNormalization) {
94
+ const { trim, lowercase, preserveCaseFor } = options.identifierNormalization;
95
+ if (trim !== undefined && typeof trim !== "boolean") {
96
+ throw new TypeError("identifierNormalization.trim must be a boolean.");
97
+ }
98
+ if (lowercase !== undefined && typeof lowercase !== "boolean") {
99
+ throw new TypeError("identifierNormalization.lowercase must be a boolean.");
100
+ }
101
+ if (preserveCaseFor !== undefined &&
102
+ (!Array.isArray(preserveCaseFor) ||
103
+ preserveCaseFor.some((channel) => typeof channel !== "string" || !channel.trim()))) {
104
+ throw new TypeError("identifierNormalization.preserveCaseFor must be an array of non-empty strings.");
105
+ }
106
+ }
76
107
  }
77
108
  function validatePayload(input) {
78
109
  if (!input.type || !input.type.trim()) {
@@ -13,6 +13,11 @@ export interface RateLimitConfig {
13
13
  window: number;
14
14
  max: number;
15
15
  }
16
+ export interface IdentifierNormalizationConfig {
17
+ trim?: boolean;
18
+ lowercase?: boolean;
19
+ preserveCaseFor?: OTPChannel[];
20
+ }
16
21
  export interface OTPManagerOptions {
17
22
  store: StoreAdapter;
18
23
  ttl: number;
@@ -20,6 +25,8 @@ export interface OTPManagerOptions {
20
25
  rateLimit?: RateLimitConfig;
21
26
  devMode?: boolean;
22
27
  otpLength?: number;
28
+ resendCooldown?: number;
29
+ identifierNormalization?: IdentifierNormalizationConfig;
23
30
  }
24
31
  export interface GenerateOTPResult {
25
32
  expiresIn: number;
@@ -14,3 +14,6 @@ export declare class OTPInvalidError extends OTPError {
14
14
  export declare class OTPMaxAttemptsExceededError extends OTPError {
15
15
  constructor(message?: string);
16
16
  }
17
+ export declare class OTPResendCooldownError extends OTPError {
18
+ constructor(message?: string);
19
+ }
@@ -26,3 +26,8 @@ export class OTPMaxAttemptsExceededError extends OTPError {
26
26
  super(message, "OTP_MAX_ATTEMPTS_EXCEEDED");
27
27
  }
28
28
  }
29
+ export class OTPResendCooldownError extends OTPError {
30
+ constructor(message = "OTP resend cooldown is active.") {
31
+ super(message, "OTP_RESEND_COOLDOWN");
32
+ }
33
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { OTPManager } from "./core/otp.service.js";
2
2
  export { RedisAdapter } from "./adapters/redis.adapter.js";
3
3
  export { MemoryAdapter } from "./adapters/memory.adapter.js";
4
- export type { GenerateOTPInput, GenerateOTPResult, OTPManagerOptions, OTPPayload, RateLimitConfig, StoreAdapter, VerifyOTPInput, } from "./core/otp.types.js";
5
- export { OTPError, OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, } from "./errors/otp.errors.js";
4
+ export type { GenerateOTPInput, GenerateOTPResult, IdentifierNormalizationConfig, OTPManagerOptions, OTPPayload, RateLimitConfig, StoreAdapter, VerifyOTPInput, } from "./core/otp.types.js";
5
+ export { OTPError, OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, OTPResendCooldownError, } from "./errors/otp.errors.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { OTPManager } from "./core/otp.service.js";
2
2
  export { RedisAdapter } from "./adapters/redis.adapter.js";
3
3
  export { MemoryAdapter } from "./adapters/memory.adapter.js";
4
- export { OTPError, OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, } from "./errors/otp.errors.js";
4
+ export { OTPError, OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, OTPResendCooldownError, } from "./errors/otp.errors.js";
@@ -0,0 +1,2 @@
1
+ import type { IdentifierNormalizationConfig, OTPPayload } from "../core/otp.types.js";
2
+ export declare function normalizePayloadIdentifier(payload: OTPPayload, config?: IdentifierNormalizationConfig): OTPPayload;
@@ -0,0 +1,27 @@
1
+ const DEFAULT_IDENTIFIER_NORMALIZATION = {
2
+ trim: true,
3
+ lowercase: true,
4
+ preserveCaseFor: ["sms", "token"],
5
+ };
6
+ export function normalizePayloadIdentifier(payload, config) {
7
+ const resolvedConfig = resolveNormalizationConfig(config);
8
+ let identifier = payload.identifier;
9
+ if (resolvedConfig.trim) {
10
+ identifier = identifier.trim();
11
+ }
12
+ const preserveCase = resolvedConfig.preserveCaseFor.includes(payload.type);
13
+ if (resolvedConfig.lowercase && !preserveCase) {
14
+ identifier = identifier.toLowerCase();
15
+ }
16
+ return {
17
+ ...payload,
18
+ identifier,
19
+ };
20
+ }
21
+ function resolveNormalizationConfig(config) {
22
+ return {
23
+ ...DEFAULT_IDENTIFIER_NORMALIZATION,
24
+ ...config,
25
+ preserveCaseFor: config?.preserveCaseFor ?? DEFAULT_IDENTIFIER_NORMALIZATION.preserveCaseFor,
26
+ };
27
+ }
@@ -2,3 +2,4 @@ import type { OTPPayload } from "../core/otp.types.js";
2
2
  export declare function buildOtpKey(payload: OTPPayload): string;
3
3
  export declare function buildAttemptsKey(payload: OTPPayload): string;
4
4
  export declare function buildRateLimitKey(payload: OTPPayload): string;
5
+ export declare function buildCooldownKey(payload: OTPPayload): string;
@@ -10,3 +10,6 @@ export function buildAttemptsKey(payload) {
10
10
  export function buildRateLimitKey(payload) {
11
11
  return `rate:${payload.type}:${payload.identifier}`;
12
12
  }
13
+ export function buildCooldownKey(payload) {
14
+ return `cooldown:${normalizeIntent(payload.intent)}:${payload.type}:${payload.identifier}`;
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redis-otp-manager",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Lightweight, Redis-backed OTP manager for Node.js apps",
5
5
  "repository": {
6
6
  "type": "git",