redis-otp-manager 0.2.3 → 0.4.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 +60 -99
- package/dist/adapters/redis.adapter.d.ts +26 -0
- package/dist/adapters/redis.adapter.js +97 -0
- package/dist/cjs/adapters/redis.adapter.js +97 -0
- package/dist/cjs/core/otp.hash.js +41 -5
- package/dist/cjs/core/otp.service.js +66 -4
- package/dist/core/otp.hash.d.ts +4 -1
- package/dist/core/otp.hash.js +40 -6
- package/dist/core/otp.service.js +68 -6
- package/dist/core/otp.types.d.ts +6 -0
- package/dist/index.d.ts +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -7,7 +7,9 @@ Lightweight, Redis-backed OTP manager for Node.js and NestJS apps.
|
|
|
7
7
|
This package currently includes:
|
|
8
8
|
- OTP generation
|
|
9
9
|
- OTP verification
|
|
10
|
-
-
|
|
10
|
+
- Keyed HMAC support with app secret configuration
|
|
11
|
+
- Secret rotation support for verification
|
|
12
|
+
- Legacy SHA-256 verification compatibility for migrations
|
|
11
13
|
- Redis-compatible storage adapter
|
|
12
14
|
- In-memory adapter for tests
|
|
13
15
|
- Intent-aware key strategy
|
|
@@ -15,6 +17,7 @@ This package currently includes:
|
|
|
15
17
|
- Rate limiting
|
|
16
18
|
- Optional resend cooldown
|
|
17
19
|
- Max-attempt protection
|
|
20
|
+
- Atomic Redis generate and verify paths using Lua scripts
|
|
18
21
|
- NestJS module integration via `redis-otp-manager/nest`
|
|
19
22
|
- Dual package support for ESM and CommonJS consumers
|
|
20
23
|
|
|
@@ -32,24 +35,10 @@ npm install @nestjs/common @nestjs/core reflect-metadata rxjs
|
|
|
32
35
|
|
|
33
36
|
## Module Support
|
|
34
37
|
|
|
35
|
-
This package
|
|
38
|
+
This package supports both:
|
|
36
39
|
- ESM imports
|
|
37
40
|
- CommonJS/Nest `ts-node/register` style resolution
|
|
38
41
|
|
|
39
|
-
ESM:
|
|
40
|
-
|
|
41
|
-
```ts
|
|
42
|
-
import { OTPManager } from "redis-otp-manager";
|
|
43
|
-
import { OTPModule } from "redis-otp-manager/nest";
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
CommonJS:
|
|
47
|
-
|
|
48
|
-
```js
|
|
49
|
-
const { OTPManager } = require("redis-otp-manager");
|
|
50
|
-
const { OTPModule } = require("redis-otp-manager/nest");
|
|
51
|
-
```
|
|
52
|
-
|
|
53
42
|
## Quality Checks
|
|
54
43
|
|
|
55
44
|
```bash
|
|
@@ -73,25 +62,52 @@ const otp = new OTPManager({
|
|
|
73
62
|
max: 3,
|
|
74
63
|
},
|
|
75
64
|
devMode: false,
|
|
65
|
+
hashing: {
|
|
66
|
+
secret: process.env.OTP_HMAC_SECRET,
|
|
67
|
+
},
|
|
76
68
|
});
|
|
69
|
+
```
|
|
77
70
|
|
|
78
|
-
|
|
79
|
-
type: "email",
|
|
80
|
-
identifier: "abc@gmail.com",
|
|
81
|
-
intent: "login",
|
|
82
|
-
});
|
|
71
|
+
## Cryptographic Hardening
|
|
83
72
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
73
|
+
When `hashing.secret` is configured, new OTPs are stored using keyed HMAC instead of plain SHA-256.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const otp = new OTPManager({
|
|
77
|
+
store: new RedisAdapter(redisClient),
|
|
78
|
+
ttl: 300,
|
|
79
|
+
maxAttempts: 3,
|
|
80
|
+
hashing: {
|
|
81
|
+
secret: process.env.OTP_HMAC_SECRET,
|
|
82
|
+
previousSecrets: [process.env.OTP_PREVIOUS_SECRET ?? ""].filter(Boolean),
|
|
83
|
+
allowLegacyVerify: true,
|
|
84
|
+
},
|
|
89
85
|
});
|
|
90
86
|
```
|
|
91
87
|
|
|
92
|
-
|
|
88
|
+
Recommended migration strategy:
|
|
89
|
+
- deploy `hashing.secret`
|
|
90
|
+
- keep `allowLegacyVerify: true` during migration
|
|
91
|
+
- optionally add `previousSecrets` during secret rotation
|
|
92
|
+
- after old in-flight OTPs naturally expire, you can disable legacy verification if desired
|
|
93
|
+
|
|
94
|
+
Secure secret management guidance:
|
|
95
|
+
- store secrets in environment variables or your secret manager
|
|
96
|
+
- never hardcode secrets in source control
|
|
97
|
+
- rotate secrets deliberately and keep the previous secret only as long as needed
|
|
98
|
+
- use different secrets across environments
|
|
99
|
+
|
|
100
|
+
## Production Security Notes
|
|
93
101
|
|
|
94
|
-
|
|
102
|
+
When you use `RedisAdapter`, the package takes the Redis-specific atomic path for:
|
|
103
|
+
- OTP generation plus rate-limit/cooldown checks
|
|
104
|
+
- OTP verification plus attempt tracking and OTP deletion
|
|
105
|
+
|
|
106
|
+
That prevents the most important race condition from earlier versions where two parallel correct verification requests could both succeed.
|
|
107
|
+
|
|
108
|
+
Simpler adapters like `MemoryAdapter` intentionally stay on the non-atomic fallback path to keep tests and local development lightweight.
|
|
109
|
+
|
|
110
|
+
## NestJS
|
|
95
111
|
|
|
96
112
|
```ts
|
|
97
113
|
import { Module } from "@nestjs/common";
|
|
@@ -112,6 +128,9 @@ const redisClient = createClient({ url: process.env.REDIS_URL });
|
|
|
112
128
|
window: 60,
|
|
113
129
|
max: 3,
|
|
114
130
|
},
|
|
131
|
+
hashing: {
|
|
132
|
+
secret: process.env.OTP_HMAC_SECRET,
|
|
133
|
+
},
|
|
115
134
|
isGlobal: true,
|
|
116
135
|
}),
|
|
117
136
|
],
|
|
@@ -119,34 +138,6 @@ const redisClient = createClient({ url: process.env.REDIS_URL });
|
|
|
119
138
|
export class AppModule {}
|
|
120
139
|
```
|
|
121
140
|
|
|
122
|
-
Async setup is also supported:
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
OTPModule.forRootAsync({
|
|
126
|
-
isGlobal: true,
|
|
127
|
-
inject: [ConfigService],
|
|
128
|
-
useFactory: (config: ConfigService) => ({
|
|
129
|
-
store: new RedisAdapter(createClient({ url: config.getOrThrow("REDIS_URL") })),
|
|
130
|
-
ttl: 300,
|
|
131
|
-
maxAttempts: 5,
|
|
132
|
-
resendCooldown: 45,
|
|
133
|
-
}),
|
|
134
|
-
});
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
Inject in services:
|
|
138
|
-
|
|
139
|
-
```ts
|
|
140
|
-
import { Injectable } from "@nestjs/common";
|
|
141
|
-
import { OTPManager } from "redis-otp-manager";
|
|
142
|
-
import { InjectOTPManager } from "redis-otp-manager/nest";
|
|
143
|
-
|
|
144
|
-
@Injectable()
|
|
145
|
-
export class AuthService {
|
|
146
|
-
constructor(@InjectOTPManager() private readonly otpManager: OTPManager) {}
|
|
147
|
-
}
|
|
148
|
-
```
|
|
149
|
-
|
|
150
141
|
## API
|
|
151
142
|
|
|
152
143
|
### `new OTPManager(options)`
|
|
@@ -168,47 +159,15 @@ type OTPManagerOptions = {
|
|
|
168
159
|
lowercase?: boolean;
|
|
169
160
|
preserveCaseFor?: string[];
|
|
170
161
|
};
|
|
162
|
+
hashing?: {
|
|
163
|
+
secret?: string;
|
|
164
|
+
previousSecrets?: string[];
|
|
165
|
+
allowLegacyVerify?: boolean;
|
|
166
|
+
};
|
|
171
167
|
};
|
|
172
168
|
```
|
|
173
169
|
|
|
174
|
-
|
|
175
|
-
- identifiers are trimmed
|
|
176
|
-
- identifiers are lowercased for most channels
|
|
177
|
-
- `sms` and `token` preserve case by default
|
|
178
|
-
|
|
179
|
-
### `generate(input)`
|
|
180
|
-
|
|
181
|
-
```ts
|
|
182
|
-
const result = await otp.generate({
|
|
183
|
-
type: "email",
|
|
184
|
-
identifier: "abc@gmail.com",
|
|
185
|
-
intent: "login",
|
|
186
|
-
});
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
Returns:
|
|
190
|
-
|
|
191
|
-
```ts
|
|
192
|
-
{
|
|
193
|
-
expiresIn: 300,
|
|
194
|
-
otp?: "123456"
|
|
195
|
-
}
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
### `verify(input)`
|
|
199
|
-
|
|
200
|
-
```ts
|
|
201
|
-
await otp.verify({
|
|
202
|
-
type: "email",
|
|
203
|
-
identifier: "abc@gmail.com",
|
|
204
|
-
intent: "login",
|
|
205
|
-
otp: "123456",
|
|
206
|
-
});
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
Returns `true` or throws a typed error.
|
|
210
|
-
|
|
211
|
-
## Errors
|
|
170
|
+
### Errors
|
|
212
171
|
|
|
213
172
|
- `OTPRateLimitExceededError`
|
|
214
173
|
- `OTPExpiredError`
|
|
@@ -221,7 +180,9 @@ Returns `true` or throws a typed error.
|
|
|
221
180
|
- `devMode: false`
|
|
222
181
|
- `otpLength: 8` for higher-risk flows
|
|
223
182
|
- `maxAttempts: 3`
|
|
224
|
-
- `resendCooldown: 30` or higher
|
|
183
|
+
- `resendCooldown: 30` or higher
|
|
184
|
+
- `hashing.secret` configured in production
|
|
185
|
+
- prefer `RedisAdapter` in production to get the atomic security path
|
|
225
186
|
- keep Redis private and behind authenticated network access
|
|
226
187
|
|
|
227
188
|
## Key Design
|
|
@@ -237,7 +198,7 @@ cooldown:{intent}:{type}:{identifier}
|
|
|
237
198
|
|
|
238
199
|
Publishing on every `main` merge is not recommended for npm packages because npm versions are immutable. The safer setup is:
|
|
239
200
|
- merge to `main` runs CI only
|
|
240
|
-
- publish happens when you push a version tag like `v0.
|
|
201
|
+
- publish happens when you push a version tag like `v0.4.0`
|
|
241
202
|
|
|
242
203
|
Required GitHub secrets:
|
|
243
204
|
- `NPM_TOKEN`
|
|
@@ -245,12 +206,12 @@ Required GitHub secrets:
|
|
|
245
206
|
Tag-based publish:
|
|
246
207
|
|
|
247
208
|
```bash
|
|
248
|
-
git tag v0.
|
|
249
|
-
git push origin v0.
|
|
209
|
+
git tag v0.4.0
|
|
210
|
+
git push origin v0.4.0
|
|
250
211
|
```
|
|
251
212
|
|
|
252
213
|
## Next Roadmap
|
|
253
214
|
|
|
254
|
-
- atomic Redis verification
|
|
255
215
|
- hooks/events
|
|
256
216
|
- analytics and observability
|
|
217
|
+
- delivery helper integrations
|
|
@@ -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
|
+
candidateHashes: 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,60 @@
|
|
|
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
|
+
local candidateCount = tonumber(ARGV[1])
|
|
35
|
+
for index = 1, candidateCount do
|
|
36
|
+
if storedHash == ARGV[index + 1] then
|
|
37
|
+
redis.call('DEL', KEYS[1])
|
|
38
|
+
redis.call('DEL', KEYS[2])
|
|
39
|
+
return 'verified'
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
local ttlIndex = candidateCount + 2
|
|
44
|
+
local maxAttemptsIndex = candidateCount + 3
|
|
45
|
+
local attempts = redis.call('INCR', KEYS[2])
|
|
46
|
+
if attempts == 1 then
|
|
47
|
+
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[ttlIndex]))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if attempts >= tonumber(ARGV[maxAttemptsIndex]) then
|
|
51
|
+
redis.call('DEL', KEYS[1])
|
|
52
|
+
redis.call('DEL', KEYS[2])
|
|
53
|
+
return 'max_attempts'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
return 'invalid'
|
|
57
|
+
`;
|
|
1
58
|
export class RedisAdapter {
|
|
2
59
|
client;
|
|
3
60
|
constructor(client) {
|
|
@@ -19,4 +76,44 @@ export class RedisAdapter {
|
|
|
19
76
|
}
|
|
20
77
|
return nextValue;
|
|
21
78
|
}
|
|
79
|
+
async generateOtpAtomically(params) {
|
|
80
|
+
if (!this.client.eval) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const result = await this.client.eval(ATOMIC_GENERATE_SCRIPT, {
|
|
84
|
+
keys: [params.otpKey, params.attemptsKey, params.rateLimitKey, params.cooldownKey],
|
|
85
|
+
arguments: [
|
|
86
|
+
params.hashedOtp,
|
|
87
|
+
String(params.ttl),
|
|
88
|
+
params.rateWindow ? String(params.rateWindow) : "",
|
|
89
|
+
params.rateMax ? String(params.rateMax) : "",
|
|
90
|
+
params.resendCooldown ? String(params.resendCooldown) : "",
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
if (result === "ok" || result === "rate_limit" || result === "cooldown") {
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
async verifyOtpAtomically(params) {
|
|
99
|
+
if (!this.client.eval) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const result = await this.client.eval(ATOMIC_VERIFY_SCRIPT, {
|
|
103
|
+
keys: [params.otpKey, params.attemptsKey],
|
|
104
|
+
arguments: [
|
|
105
|
+
String(params.candidateHashes.length),
|
|
106
|
+
...params.candidateHashes,
|
|
107
|
+
String(params.ttl),
|
|
108
|
+
String(params.maxAttempts),
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
if (result === "verified" ||
|
|
112
|
+
result === "expired" ||
|
|
113
|
+
result === "invalid" ||
|
|
114
|
+
result === "max_attempts") {
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
22
119
|
}
|
|
@@ -1,6 +1,63 @@
|
|
|
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
|
+
local candidateCount = tonumber(ARGV[1])
|
|
38
|
+
for index = 1, candidateCount do
|
|
39
|
+
if storedHash == ARGV[index + 1] then
|
|
40
|
+
redis.call('DEL', KEYS[1])
|
|
41
|
+
redis.call('DEL', KEYS[2])
|
|
42
|
+
return 'verified'
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
local ttlIndex = candidateCount + 2
|
|
47
|
+
local maxAttemptsIndex = candidateCount + 3
|
|
48
|
+
local attempts = redis.call('INCR', KEYS[2])
|
|
49
|
+
if attempts == 1 then
|
|
50
|
+
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[ttlIndex]))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if attempts >= tonumber(ARGV[maxAttemptsIndex]) then
|
|
54
|
+
redis.call('DEL', KEYS[1])
|
|
55
|
+
redis.call('DEL', KEYS[2])
|
|
56
|
+
return 'max_attempts'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
return 'invalid'
|
|
60
|
+
`;
|
|
4
61
|
class RedisAdapter {
|
|
5
62
|
client;
|
|
6
63
|
constructor(client) {
|
|
@@ -22,5 +79,45 @@ class RedisAdapter {
|
|
|
22
79
|
}
|
|
23
80
|
return nextValue;
|
|
24
81
|
}
|
|
82
|
+
async generateOtpAtomically(params) {
|
|
83
|
+
if (!this.client.eval) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const result = await this.client.eval(ATOMIC_GENERATE_SCRIPT, {
|
|
87
|
+
keys: [params.otpKey, params.attemptsKey, params.rateLimitKey, params.cooldownKey],
|
|
88
|
+
arguments: [
|
|
89
|
+
params.hashedOtp,
|
|
90
|
+
String(params.ttl),
|
|
91
|
+
params.rateWindow ? String(params.rateWindow) : "",
|
|
92
|
+
params.rateMax ? String(params.rateMax) : "",
|
|
93
|
+
params.resendCooldown ? String(params.resendCooldown) : "",
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
if (result === "ok" || result === "rate_limit" || result === "cooldown") {
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
async verifyOtpAtomically(params) {
|
|
102
|
+
if (!this.client.eval) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const result = await this.client.eval(ATOMIC_VERIFY_SCRIPT, {
|
|
106
|
+
keys: [params.otpKey, params.attemptsKey],
|
|
107
|
+
arguments: [
|
|
108
|
+
String(params.candidateHashes.length),
|
|
109
|
+
...params.candidateHashes,
|
|
110
|
+
String(params.ttl),
|
|
111
|
+
String(params.maxAttempts),
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
if (result === "verified" ||
|
|
115
|
+
result === "expired" ||
|
|
116
|
+
result === "invalid" ||
|
|
117
|
+
result === "max_attempts") {
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
25
122
|
}
|
|
26
123
|
exports.RedisAdapter = RedisAdapter;
|
|
@@ -1,16 +1,52 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.hashOtp = hashOtp;
|
|
4
|
+
exports.createStoredOtpHash = createStoredOtpHash;
|
|
5
|
+
exports.buildVerificationHashes = buildVerificationHashes;
|
|
4
6
|
exports.verifyOtpHash = verifyOtpHash;
|
|
5
7
|
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const LEGACY_PREFIX = "sha256:";
|
|
9
|
+
const HMAC_PREFIX = "hmac-sha256:";
|
|
6
10
|
function hashOtp(otp) {
|
|
7
11
|
return (0, node_crypto_1.createHash)("sha256").update(otp).digest("hex");
|
|
8
12
|
}
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
function createStoredOtpHash(otp, payload, hashing) {
|
|
14
|
+
if (hashing?.secret) {
|
|
15
|
+
return `${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, hashing.secret)}`;
|
|
16
|
+
}
|
|
17
|
+
return `${LEGACY_PREFIX}${hashOtp(otp)}`;
|
|
18
|
+
}
|
|
19
|
+
function buildVerificationHashes(otp, payload, hashing) {
|
|
20
|
+
const candidates = new Set();
|
|
21
|
+
if (hashing?.secret) {
|
|
22
|
+
candidates.add(`${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, hashing.secret)}`);
|
|
23
|
+
for (const secret of hashing.previousSecrets ?? []) {
|
|
24
|
+
candidates.add(`${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, secret)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const allowLegacyVerify = hashing?.allowLegacyVerify ?? true;
|
|
28
|
+
if (!hashing?.secret || allowLegacyVerify) {
|
|
29
|
+
const legacyHash = hashOtp(otp);
|
|
30
|
+
candidates.add(legacyHash);
|
|
31
|
+
candidates.add(`${LEGACY_PREFIX}${legacyHash}`);
|
|
32
|
+
}
|
|
33
|
+
return [...candidates];
|
|
34
|
+
}
|
|
35
|
+
function verifyOtpHash(otp, payload, expectedHash, hashing) {
|
|
36
|
+
const candidates = buildVerificationHashes(otp, payload, hashing);
|
|
37
|
+
return candidates.some((candidate) => safeCompareHashes(candidate, expectedHash));
|
|
38
|
+
}
|
|
39
|
+
function hashOtpWithSecret(otp, payload, secret) {
|
|
40
|
+
return (0, node_crypto_1.createHmac)("sha256", secret).update(buildHmacMaterial(otp, payload)).digest("hex");
|
|
41
|
+
}
|
|
42
|
+
function buildHmacMaterial(otp, payload) {
|
|
43
|
+
return [payload.intent ?? "default", payload.type, payload.identifier, otp].join(":");
|
|
44
|
+
}
|
|
45
|
+
function safeCompareHashes(candidate, expectedHash) {
|
|
46
|
+
const candidateBuffer = Buffer.from(candidate);
|
|
47
|
+
const expectedBuffer = Buffer.from(expectedHash);
|
|
48
|
+
if (candidateBuffer.length !== expectedBuffer.length) {
|
|
13
49
|
return false;
|
|
14
50
|
}
|
|
15
|
-
return (0, node_crypto_1.timingSafeEqual)(
|
|
51
|
+
return (0, node_crypto_1.timingSafeEqual)(candidateBuffer, expectedBuffer);
|
|
16
52
|
}
|
|
@@ -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 storedHash = (0, otp_hash_js_1.createStoredOtpHash)(otp, normalizedInput, this.options.hashing);
|
|
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: storedHash,
|
|
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,9 +59,7 @@ class OTPManager {
|
|
|
31
59
|
}
|
|
32
60
|
}
|
|
33
61
|
await (0, rate_limiter_js_1.assertWithinRateLimit)(this.options.store, rateLimitKey, this.options.rateLimit);
|
|
34
|
-
|
|
35
|
-
const hashedOtp = (0, otp_hash_js_1.hashOtp)(otp);
|
|
36
|
-
await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
|
|
62
|
+
await this.options.store.set(otpKey, storedHash, this.options.ttl);
|
|
37
63
|
await this.options.store.del(attemptsKey);
|
|
38
64
|
if (this.options.resendCooldown) {
|
|
39
65
|
await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
|
|
@@ -51,11 +77,33 @@ 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
|
+
const candidateHashes = (0, otp_hash_js_1.buildVerificationHashes)(input.otp, normalizedInput, this.options.hashing);
|
|
81
|
+
if (this.options.store instanceof redis_adapter_js_1.RedisAdapter) {
|
|
82
|
+
const atomicResult = await this.options.store.verifyOtpAtomically({
|
|
83
|
+
otpKey,
|
|
84
|
+
attemptsKey,
|
|
85
|
+
candidateHashes,
|
|
86
|
+
ttl: this.options.ttl,
|
|
87
|
+
maxAttempts: this.options.maxAttempts,
|
|
88
|
+
});
|
|
89
|
+
if (atomicResult === "verified") {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (atomicResult === "expired") {
|
|
93
|
+
throw new otp_errors_js_1.OTPExpiredError();
|
|
94
|
+
}
|
|
95
|
+
if (atomicResult === "max_attempts") {
|
|
96
|
+
throw new otp_errors_js_1.OTPMaxAttemptsExceededError();
|
|
97
|
+
}
|
|
98
|
+
if (atomicResult === "invalid") {
|
|
99
|
+
throw new otp_errors_js_1.OTPInvalidError();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
54
102
|
const storedHash = await this.options.store.get(otpKey);
|
|
55
103
|
if (!storedHash) {
|
|
56
104
|
throw new otp_errors_js_1.OTPExpiredError();
|
|
57
105
|
}
|
|
58
|
-
const isValid = (0, otp_hash_js_1.verifyOtpHash)(input.otp, storedHash);
|
|
106
|
+
const isValid = (0, otp_hash_js_1.verifyOtpHash)(input.otp, normalizedInput, storedHash, this.options.hashing);
|
|
59
107
|
if (!isValid) {
|
|
60
108
|
const attempts = await this.options.store.increment(attemptsKey, this.options.ttl);
|
|
61
109
|
if (attempts >= this.options.maxAttempts) {
|
|
@@ -108,6 +156,20 @@ function validateManagerOptions(options) {
|
|
|
108
156
|
throw new TypeError("identifierNormalization.preserveCaseFor must be an array of non-empty strings.");
|
|
109
157
|
}
|
|
110
158
|
}
|
|
159
|
+
if (options.hashing) {
|
|
160
|
+
if (options.hashing.secret !== undefined && !options.hashing.secret.trim()) {
|
|
161
|
+
throw new TypeError("hashing.secret must be a non-empty string when provided.");
|
|
162
|
+
}
|
|
163
|
+
if (options.hashing.previousSecrets !== undefined &&
|
|
164
|
+
(!Array.isArray(options.hashing.previousSecrets) ||
|
|
165
|
+
options.hashing.previousSecrets.some((secret) => !secret || !secret.trim()))) {
|
|
166
|
+
throw new TypeError("hashing.previousSecrets must contain only non-empty strings.");
|
|
167
|
+
}
|
|
168
|
+
if (options.hashing.allowLegacyVerify !== undefined &&
|
|
169
|
+
typeof options.hashing.allowLegacyVerify !== "boolean") {
|
|
170
|
+
throw new TypeError("hashing.allowLegacyVerify must be a boolean.");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
111
173
|
}
|
|
112
174
|
function validatePayload(input) {
|
|
113
175
|
if (!input.type || !input.type.trim()) {
|
package/dist/core/otp.hash.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
+
import type { OTPHashingOptions, OTPPayload } from "./otp.types.js";
|
|
1
2
|
export declare function hashOtp(otp: string): string;
|
|
2
|
-
export declare function
|
|
3
|
+
export declare function createStoredOtpHash(otp: string, payload: OTPPayload, hashing?: OTPHashingOptions): string;
|
|
4
|
+
export declare function buildVerificationHashes(otp: string, payload: OTPPayload, hashing?: OTPHashingOptions): string[];
|
|
5
|
+
export declare function verifyOtpHash(otp: string, payload: OTPPayload, expectedHash: string, hashing?: OTPHashingOptions): boolean;
|
package/dist/core/otp.hash.js
CHANGED
|
@@ -1,12 +1,46 @@
|
|
|
1
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const LEGACY_PREFIX = "sha256:";
|
|
3
|
+
const HMAC_PREFIX = "hmac-sha256:";
|
|
2
4
|
export function hashOtp(otp) {
|
|
3
5
|
return createHash("sha256").update(otp).digest("hex");
|
|
4
6
|
}
|
|
5
|
-
export function
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
export function createStoredOtpHash(otp, payload, hashing) {
|
|
8
|
+
if (hashing?.secret) {
|
|
9
|
+
return `${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, hashing.secret)}`;
|
|
10
|
+
}
|
|
11
|
+
return `${LEGACY_PREFIX}${hashOtp(otp)}`;
|
|
12
|
+
}
|
|
13
|
+
export function buildVerificationHashes(otp, payload, hashing) {
|
|
14
|
+
const candidates = new Set();
|
|
15
|
+
if (hashing?.secret) {
|
|
16
|
+
candidates.add(`${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, hashing.secret)}`);
|
|
17
|
+
for (const secret of hashing.previousSecrets ?? []) {
|
|
18
|
+
candidates.add(`${HMAC_PREFIX}${hashOtpWithSecret(otp, payload, secret)}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const allowLegacyVerify = hashing?.allowLegacyVerify ?? true;
|
|
22
|
+
if (!hashing?.secret || allowLegacyVerify) {
|
|
23
|
+
const legacyHash = hashOtp(otp);
|
|
24
|
+
candidates.add(legacyHash);
|
|
25
|
+
candidates.add(`${LEGACY_PREFIX}${legacyHash}`);
|
|
26
|
+
}
|
|
27
|
+
return [...candidates];
|
|
28
|
+
}
|
|
29
|
+
export function verifyOtpHash(otp, payload, expectedHash, hashing) {
|
|
30
|
+
const candidates = buildVerificationHashes(otp, payload, hashing);
|
|
31
|
+
return candidates.some((candidate) => safeCompareHashes(candidate, expectedHash));
|
|
32
|
+
}
|
|
33
|
+
function hashOtpWithSecret(otp, payload, secret) {
|
|
34
|
+
return createHmac("sha256", secret).update(buildHmacMaterial(otp, payload)).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
function buildHmacMaterial(otp, payload) {
|
|
37
|
+
return [payload.intent ?? "default", payload.type, payload.identifier, otp].join(":");
|
|
38
|
+
}
|
|
39
|
+
function safeCompareHashes(candidate, expectedHash) {
|
|
40
|
+
const candidateBuffer = Buffer.from(candidate);
|
|
41
|
+
const expectedBuffer = Buffer.from(expectedHash);
|
|
42
|
+
if (candidateBuffer.length !== expectedBuffer.length) {
|
|
9
43
|
return false;
|
|
10
44
|
}
|
|
11
|
-
return timingSafeEqual(
|
|
45
|
+
return timingSafeEqual(candidateBuffer, expectedBuffer);
|
|
12
46
|
}
|
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
|
-
import {
|
|
3
|
-
import { OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPResendCooldownError, } from "../errors/otp.errors.js";
|
|
3
|
+
import { buildVerificationHashes, createStoredOtpHash, verifyOtpHash } from "./otp.hash.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 storedHash = createStoredOtpHash(otp, normalizedInput, this.options.hashing);
|
|
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: storedHash,
|
|
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,9 +56,7 @@ export class OTPManager {
|
|
|
28
56
|
}
|
|
29
57
|
}
|
|
30
58
|
await assertWithinRateLimit(this.options.store, rateLimitKey, this.options.rateLimit);
|
|
31
|
-
|
|
32
|
-
const hashedOtp = hashOtp(otp);
|
|
33
|
-
await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
|
|
59
|
+
await this.options.store.set(otpKey, storedHash, this.options.ttl);
|
|
34
60
|
await this.options.store.del(attemptsKey);
|
|
35
61
|
if (this.options.resendCooldown) {
|
|
36
62
|
await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
|
|
@@ -48,11 +74,33 @@ export class OTPManager {
|
|
|
48
74
|
}
|
|
49
75
|
const otpKey = buildOtpKey(normalizedInput);
|
|
50
76
|
const attemptsKey = buildAttemptsKey(normalizedInput);
|
|
77
|
+
const candidateHashes = buildVerificationHashes(input.otp, normalizedInput, this.options.hashing);
|
|
78
|
+
if (this.options.store instanceof RedisAdapter) {
|
|
79
|
+
const atomicResult = await this.options.store.verifyOtpAtomically({
|
|
80
|
+
otpKey,
|
|
81
|
+
attemptsKey,
|
|
82
|
+
candidateHashes,
|
|
83
|
+
ttl: this.options.ttl,
|
|
84
|
+
maxAttempts: this.options.maxAttempts,
|
|
85
|
+
});
|
|
86
|
+
if (atomicResult === "verified") {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (atomicResult === "expired") {
|
|
90
|
+
throw new OTPExpiredError();
|
|
91
|
+
}
|
|
92
|
+
if (atomicResult === "max_attempts") {
|
|
93
|
+
throw new OTPMaxAttemptsExceededError();
|
|
94
|
+
}
|
|
95
|
+
if (atomicResult === "invalid") {
|
|
96
|
+
throw new OTPInvalidError();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
51
99
|
const storedHash = await this.options.store.get(otpKey);
|
|
52
100
|
if (!storedHash) {
|
|
53
101
|
throw new OTPExpiredError();
|
|
54
102
|
}
|
|
55
|
-
const isValid = verifyOtpHash(input.otp, storedHash);
|
|
103
|
+
const isValid = verifyOtpHash(input.otp, normalizedInput, storedHash, this.options.hashing);
|
|
56
104
|
if (!isValid) {
|
|
57
105
|
const attempts = await this.options.store.increment(attemptsKey, this.options.ttl);
|
|
58
106
|
if (attempts >= this.options.maxAttempts) {
|
|
@@ -104,6 +152,20 @@ function validateManagerOptions(options) {
|
|
|
104
152
|
throw new TypeError("identifierNormalization.preserveCaseFor must be an array of non-empty strings.");
|
|
105
153
|
}
|
|
106
154
|
}
|
|
155
|
+
if (options.hashing) {
|
|
156
|
+
if (options.hashing.secret !== undefined && !options.hashing.secret.trim()) {
|
|
157
|
+
throw new TypeError("hashing.secret must be a non-empty string when provided.");
|
|
158
|
+
}
|
|
159
|
+
if (options.hashing.previousSecrets !== undefined &&
|
|
160
|
+
(!Array.isArray(options.hashing.previousSecrets) ||
|
|
161
|
+
options.hashing.previousSecrets.some((secret) => !secret || !secret.trim()))) {
|
|
162
|
+
throw new TypeError("hashing.previousSecrets must contain only non-empty strings.");
|
|
163
|
+
}
|
|
164
|
+
if (options.hashing.allowLegacyVerify !== undefined &&
|
|
165
|
+
typeof options.hashing.allowLegacyVerify !== "boolean") {
|
|
166
|
+
throw new TypeError("hashing.allowLegacyVerify must be a boolean.");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
107
169
|
}
|
|
108
170
|
function validatePayload(input) {
|
|
109
171
|
if (!input.type || !input.type.trim()) {
|
package/dist/core/otp.types.d.ts
CHANGED
|
@@ -18,6 +18,11 @@ export interface IdentifierNormalizationConfig {
|
|
|
18
18
|
lowercase?: boolean;
|
|
19
19
|
preserveCaseFor?: OTPChannel[];
|
|
20
20
|
}
|
|
21
|
+
export interface OTPHashingOptions {
|
|
22
|
+
secret?: string;
|
|
23
|
+
previousSecrets?: string[];
|
|
24
|
+
allowLegacyVerify?: boolean;
|
|
25
|
+
}
|
|
21
26
|
export interface OTPManagerOptions {
|
|
22
27
|
store: StoreAdapter;
|
|
23
28
|
ttl: number;
|
|
@@ -27,6 +32,7 @@ export interface OTPManagerOptions {
|
|
|
27
32
|
otpLength?: number;
|
|
28
33
|
resendCooldown?: number;
|
|
29
34
|
identifierNormalization?: IdentifierNormalizationConfig;
|
|
35
|
+
hashing?: OTPHashingOptions;
|
|
30
36
|
}
|
|
31
37
|
export interface GenerateOTPResult {
|
|
32
38
|
expiresIn: number;
|
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, IdentifierNormalizationConfig, OTPManagerOptions, OTPPayload, RateLimitConfig, StoreAdapter, VerifyOTPInput, } from "./core/otp.types.js";
|
|
4
|
+
export type { GenerateOTPInput, GenerateOTPResult, IdentifierNormalizationConfig, OTPHashingOptions, OTPManagerOptions, OTPPayload, RateLimitConfig, StoreAdapter, VerifyOTPInput, } from "./core/otp.types.js";
|
|
5
5
|
export { OTPError, OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, OTPResendCooldownError, } from "./errors/otp.errors.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redis-otp-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Lightweight, Redis-backed OTP manager for Node.js apps",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
39
39
|
"build": "npm run clean && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/prepare-cjs.cjs",
|
|
40
|
-
"test": "tsx --test test
|
|
40
|
+
"test": "tsx --test test/*.test.ts",
|
|
41
41
|
"check": "npm run build && npm run test",
|
|
42
42
|
"prepublishOnly": "npm run build"
|
|
43
43
|
},
|
|
@@ -91,3 +91,4 @@
|
|
|
91
91
|
"typescript": "^5.8.0"
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
|