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 +28 -2
- package/dist/core/otp.service.js +38 -7
- package/dist/core/otp.types.d.ts +7 -0
- package/dist/errors/otp.errors.d.ts +3 -0
- package/dist/errors/otp.errors.js +5 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/utils/identifier-normalizer.d.ts +2 -0
- package/dist/utils/identifier-normalizer.js +27 -0
- package/dist/utils/key-builder.d.ts +1 -0
- package/dist/utils/key-builder.js +3 -0
- package/package.json +1 -1
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:
|
|
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
|
-
-
|
|
233
|
+
- atomic Redis verification
|
|
208
234
|
- hooks/events
|
|
209
235
|
- analytics and observability
|
package/dist/core/otp.service.js
CHANGED
|
@@ -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
|
|
19
|
-
const
|
|
20
|
-
const
|
|
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(
|
|
37
|
-
const attemptsKey = buildAttemptsKey(
|
|
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()) {
|
package/dist/core/otp.types.d.ts
CHANGED
|
@@ -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,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
|
+
}
|