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 +38 -5
- package/dist/adapters/redis.adapter.d.ts +26 -0
- package/dist/adapters/redis.adapter.js +87 -0
- package/dist/cjs/adapters/memory.adapter.js +40 -0
- package/dist/cjs/adapters/redis.adapter.js +113 -0
- package/dist/cjs/core/otp.generator.js +14 -0
- package/dist/cjs/core/otp.hash.js +16 -0
- package/dist/cjs/core/otp.service.js +166 -0
- package/dist/cjs/core/otp.types.js +2 -0
- package/dist/cjs/errors/otp.errors.js +42 -0
- package/dist/cjs/index.js +16 -0
- package/dist/cjs/integrations/nest/index.js +8 -0
- package/dist/cjs/integrations/nest/otp.decorator.js +8 -0
- package/dist/cjs/integrations/nest/otp.module.js +92 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/utils/identifier-normalizer.js +30 -0
- package/dist/cjs/utils/key-builder.js +21 -0
- package/dist/cjs/utils/rate-limiter.js +13 -0
- package/dist/core/otp.service.js +50 -3
- package/package.json +9 -5
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.
|
|
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.
|
|
228
|
-
git push origin v0.
|
|
260
|
+
git tag v0.3.0
|
|
261
|
+
git push origin v0.3.0
|
|
229
262
|
```
|
|
230
263
|
|
|
231
264
|
## Next Roadmap
|
|
232
265
|
|
|
233
|
-
-
|
|
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,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,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
|
+
}
|
package/dist/core/otp.service.js
CHANGED
|
@@ -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.
|
|
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"
|