redis-otp-manager 0.4.0 → 0.5.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 +68 -62
- package/dist/cjs/core/otp.service.js +146 -3
- package/dist/core/otp.service.d.ts +9 -0
- package/dist/core/otp.service.js +146 -3
- package/dist/core/otp.types.d.ts +46 -0
- package/dist/index.d.ts +1 -1
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ This package currently includes:
|
|
|
10
10
|
- Keyed HMAC support with app secret configuration
|
|
11
11
|
- Secret rotation support for verification
|
|
12
12
|
- Legacy SHA-256 verification compatibility for migrations
|
|
13
|
+
- Hook-based observability events
|
|
14
|
+
- Structured metadata support for audit and logging context
|
|
13
15
|
- Redis-compatible storage adapter
|
|
14
16
|
- In-memory adapter for tests
|
|
15
17
|
- Intent-aware key strategy
|
|
@@ -33,12 +35,6 @@ For NestJS apps, also install the Nest peer dependencies used by your app:
|
|
|
33
35
|
npm install @nestjs/common @nestjs/core reflect-metadata rxjs
|
|
34
36
|
```
|
|
35
37
|
|
|
36
|
-
## Module Support
|
|
37
|
-
|
|
38
|
-
This package supports both:
|
|
39
|
-
- ESM imports
|
|
40
|
-
- CommonJS/Nest `ts-node/register` style resolution
|
|
41
|
-
|
|
42
38
|
## Quality Checks
|
|
43
39
|
|
|
44
40
|
```bash
|
|
@@ -68,6 +64,59 @@ const otp = new OTPManager({
|
|
|
68
64
|
});
|
|
69
65
|
```
|
|
70
66
|
|
|
67
|
+
## Observability Hooks
|
|
68
|
+
|
|
69
|
+
`v0.5.0` adds hook-based observability without changing the core OTP flow.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const otp = new OTPManager({
|
|
73
|
+
store: new RedisAdapter(redisClient),
|
|
74
|
+
ttl: 300,
|
|
75
|
+
maxAttempts: 3,
|
|
76
|
+
hooks: {
|
|
77
|
+
onGenerated: async (event) => logger.info("otp_generated", event),
|
|
78
|
+
onVerified: async (event) => logger.info("otp_verified", event),
|
|
79
|
+
onFailed: async (event) => logger.warn("otp_failed", event),
|
|
80
|
+
onLocked: async (event) => logger.warn("otp_locked", event),
|
|
81
|
+
onRateLimited: async (event) => logger.warn("otp_rate_limited", event),
|
|
82
|
+
onCooldownBlocked: async (event) => logger.warn("otp_cooldown_blocked", event),
|
|
83
|
+
onHookError: async (error, context) => logger.error("otp_hook_error", { error, context }),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Supported lifecycle hooks:
|
|
89
|
+
- `onGenerated`
|
|
90
|
+
- `onVerified`
|
|
91
|
+
- `onFailed`
|
|
92
|
+
- `onLocked`
|
|
93
|
+
- `onRateLimited`
|
|
94
|
+
- `onCooldownBlocked`
|
|
95
|
+
- `onHookError`
|
|
96
|
+
|
|
97
|
+
Default behavior:
|
|
98
|
+
- hooks are optional
|
|
99
|
+
- hook execution is non-blocking by default
|
|
100
|
+
- hook failures do not break OTP generation or verification unless `throwOnError: true`
|
|
101
|
+
|
|
102
|
+
## Structured Metadata
|
|
103
|
+
|
|
104
|
+
You can pass request-scoped metadata into both `generate()` and `verify()`.
|
|
105
|
+
This metadata is forwarded to hooks but is not stored in Redis.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
await otp.generate({
|
|
109
|
+
type: "email",
|
|
110
|
+
identifier: "user@example.com",
|
|
111
|
+
intent: "login",
|
|
112
|
+
metadata: {
|
|
113
|
+
requestId: "req_123",
|
|
114
|
+
userId: "user_42",
|
|
115
|
+
ip: "203.0.113.10",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
71
120
|
## Cryptographic Hardening
|
|
72
121
|
|
|
73
122
|
When `hashing.secret` is configured, new OTPs are stored using keyed HMAC instead of plain SHA-256.
|
|
@@ -91,12 +140,6 @@ Recommended migration strategy:
|
|
|
91
140
|
- optionally add `previousSecrets` during secret rotation
|
|
92
141
|
- after old in-flight OTPs naturally expire, you can disable legacy verification if desired
|
|
93
142
|
|
|
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
143
|
## Production Security Notes
|
|
101
144
|
|
|
102
145
|
When you use `RedisAdapter`, the package takes the Redis-specific atomic path for:
|
|
@@ -107,37 +150,6 @@ That prevents the most important race condition from earlier versions where two
|
|
|
107
150
|
|
|
108
151
|
Simpler adapters like `MemoryAdapter` intentionally stay on the non-atomic fallback path to keep tests and local development lightweight.
|
|
109
152
|
|
|
110
|
-
## NestJS
|
|
111
|
-
|
|
112
|
-
```ts
|
|
113
|
-
import { Module } from "@nestjs/common";
|
|
114
|
-
import { createClient } from "redis";
|
|
115
|
-
import { OTPManager, RedisAdapter } from "redis-otp-manager";
|
|
116
|
-
import { OTPModule, InjectOTPManager } from "redis-otp-manager/nest";
|
|
117
|
-
|
|
118
|
-
const redisClient = createClient({ url: process.env.REDIS_URL });
|
|
119
|
-
|
|
120
|
-
@Module({
|
|
121
|
-
imports: [
|
|
122
|
-
OTPModule.forRoot({
|
|
123
|
-
store: new RedisAdapter(redisClient),
|
|
124
|
-
ttl: 300,
|
|
125
|
-
maxAttempts: 5,
|
|
126
|
-
resendCooldown: 45,
|
|
127
|
-
rateLimit: {
|
|
128
|
-
window: 60,
|
|
129
|
-
max: 3,
|
|
130
|
-
},
|
|
131
|
-
hashing: {
|
|
132
|
-
secret: process.env.OTP_HMAC_SECRET,
|
|
133
|
-
},
|
|
134
|
-
isGlobal: true,
|
|
135
|
-
}),
|
|
136
|
-
],
|
|
137
|
-
})
|
|
138
|
-
export class AppModule {}
|
|
139
|
-
```
|
|
140
|
-
|
|
141
153
|
## API
|
|
142
154
|
|
|
143
155
|
### `new OTPManager(options)`
|
|
@@ -164,6 +176,16 @@ type OTPManagerOptions = {
|
|
|
164
176
|
previousSecrets?: string[];
|
|
165
177
|
allowLegacyVerify?: boolean;
|
|
166
178
|
};
|
|
179
|
+
hooks?: {
|
|
180
|
+
onGenerated?: (event) => void | Promise<void>;
|
|
181
|
+
onVerified?: (event) => void | Promise<void>;
|
|
182
|
+
onFailed?: (event) => void | Promise<void>;
|
|
183
|
+
onLocked?: (event) => void | Promise<void>;
|
|
184
|
+
onRateLimited?: (event) => void | Promise<void>;
|
|
185
|
+
onCooldownBlocked?: (event) => void | Promise<void>;
|
|
186
|
+
onHookError?: (error, context) => void | Promise<void>;
|
|
187
|
+
throwOnError?: boolean;
|
|
188
|
+
};
|
|
167
189
|
};
|
|
168
190
|
```
|
|
169
191
|
|
|
@@ -185,33 +207,17 @@ type OTPManagerOptions = {
|
|
|
185
207
|
- prefer `RedisAdapter` in production to get the atomic security path
|
|
186
208
|
- keep Redis private and behind authenticated network access
|
|
187
209
|
|
|
188
|
-
## Key Design
|
|
189
|
-
|
|
190
|
-
```txt
|
|
191
|
-
otp:{intent}:{type}:{identifier}
|
|
192
|
-
attempts:{intent}:{type}:{identifier}
|
|
193
|
-
rate:{type}:{identifier}
|
|
194
|
-
cooldown:{intent}:{type}:{identifier}
|
|
195
|
-
```
|
|
196
|
-
|
|
197
210
|
## Release Automation
|
|
198
211
|
|
|
199
212
|
Publishing on every `main` merge is not recommended for npm packages because npm versions are immutable. The safer setup is:
|
|
200
213
|
- merge to `main` runs CI only
|
|
201
|
-
- publish happens when you push a version tag like `v0.
|
|
214
|
+
- publish happens when you push a version tag like `v0.5.0`
|
|
202
215
|
|
|
203
216
|
Required GitHub secrets:
|
|
204
217
|
- `NPM_TOKEN`
|
|
205
218
|
|
|
206
|
-
Tag-based publish:
|
|
207
|
-
|
|
208
|
-
```bash
|
|
209
|
-
git tag v0.4.0
|
|
210
|
-
git push origin v0.4.0
|
|
211
|
-
```
|
|
212
|
-
|
|
213
219
|
## Next Roadmap
|
|
214
220
|
|
|
215
|
-
-
|
|
216
|
-
- analytics and observability
|
|
221
|
+
- analytics and observability integrations
|
|
217
222
|
- delivery helper integrations
|
|
223
|
+
- more advanced audit persistence adapters
|
|
@@ -21,6 +21,7 @@ class OTPManager {
|
|
|
21
21
|
async generate(input) {
|
|
22
22
|
validatePayload(input);
|
|
23
23
|
const normalizedInput = (0, identifier_normalizer_js_1.normalizePayloadIdentifier)(input, this.options.identifierNormalization);
|
|
24
|
+
const eventContext = this.buildEventContext(input, normalizedInput.identifier);
|
|
24
25
|
const otpKey = (0, key_builder_js_1.buildOtpKey)(normalizedInput);
|
|
25
26
|
const attemptsKey = (0, key_builder_js_1.buildAttemptsKey)(normalizedInput);
|
|
26
27
|
const rateLimitKey = (0, key_builder_js_1.buildRateLimitKey)(normalizedInput);
|
|
@@ -40,38 +41,54 @@ class OTPManager {
|
|
|
40
41
|
resendCooldown: this.options.resendCooldown,
|
|
41
42
|
});
|
|
42
43
|
if (atomicResult === "cooldown") {
|
|
44
|
+
await this.emitCooldownBlocked(eventContext);
|
|
43
45
|
throw new otp_errors_js_1.OTPResendCooldownError();
|
|
44
46
|
}
|
|
45
47
|
if (atomicResult === "rate_limit") {
|
|
48
|
+
await this.emitRateLimited(eventContext);
|
|
46
49
|
throw new otp_errors_js_1.OTPRateLimitExceededError();
|
|
47
50
|
}
|
|
48
51
|
if (atomicResult === "ok") {
|
|
49
|
-
|
|
52
|
+
const result = {
|
|
50
53
|
expiresIn: this.options.ttl,
|
|
51
54
|
otp: this.options.devMode ? otp : undefined,
|
|
52
55
|
};
|
|
56
|
+
await this.emitGenerated(eventContext);
|
|
57
|
+
return result;
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
if (this.options.resendCooldown) {
|
|
56
61
|
const cooldownActive = await this.options.store.get(cooldownKey);
|
|
57
62
|
if (cooldownActive) {
|
|
63
|
+
await this.emitCooldownBlocked(eventContext);
|
|
58
64
|
throw new otp_errors_js_1.OTPResendCooldownError();
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
|
-
|
|
67
|
+
try {
|
|
68
|
+
await (0, rate_limiter_js_1.assertWithinRateLimit)(this.options.store, rateLimitKey, this.options.rateLimit);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof otp_errors_js_1.OTPRateLimitExceededError) {
|
|
72
|
+
await this.emitRateLimited(eventContext);
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
62
76
|
await this.options.store.set(otpKey, storedHash, this.options.ttl);
|
|
63
77
|
await this.options.store.del(attemptsKey);
|
|
64
78
|
if (this.options.resendCooldown) {
|
|
65
79
|
await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
|
|
66
80
|
}
|
|
67
|
-
|
|
81
|
+
const result = {
|
|
68
82
|
expiresIn: this.options.ttl,
|
|
69
83
|
otp: this.options.devMode ? otp : undefined,
|
|
70
84
|
};
|
|
85
|
+
await this.emitGenerated(eventContext);
|
|
86
|
+
return result;
|
|
71
87
|
}
|
|
72
88
|
async verify(input) {
|
|
73
89
|
validatePayload(input);
|
|
74
90
|
const normalizedInput = (0, identifier_normalizer_js_1.normalizePayloadIdentifier)(input, this.options.identifierNormalization);
|
|
91
|
+
const eventContext = this.buildEventContext(input, normalizedInput.identifier);
|
|
75
92
|
if (!input.otp || !input.otp.trim()) {
|
|
76
93
|
throw new TypeError("OTP must be a non-empty string.");
|
|
77
94
|
}
|
|
@@ -87,20 +104,25 @@ class OTPManager {
|
|
|
87
104
|
maxAttempts: this.options.maxAttempts,
|
|
88
105
|
});
|
|
89
106
|
if (atomicResult === "verified") {
|
|
107
|
+
await this.emitVerified(eventContext);
|
|
90
108
|
return true;
|
|
91
109
|
}
|
|
92
110
|
if (atomicResult === "expired") {
|
|
111
|
+
await this.emitFailed(eventContext, "expired");
|
|
93
112
|
throw new otp_errors_js_1.OTPExpiredError();
|
|
94
113
|
}
|
|
95
114
|
if (atomicResult === "max_attempts") {
|
|
115
|
+
await this.emitLocked(eventContext);
|
|
96
116
|
throw new otp_errors_js_1.OTPMaxAttemptsExceededError();
|
|
97
117
|
}
|
|
98
118
|
if (atomicResult === "invalid") {
|
|
119
|
+
await this.emitFailed(eventContext, "invalid");
|
|
99
120
|
throw new otp_errors_js_1.OTPInvalidError();
|
|
100
121
|
}
|
|
101
122
|
}
|
|
102
123
|
const storedHash = await this.options.store.get(otpKey);
|
|
103
124
|
if (!storedHash) {
|
|
125
|
+
await this.emitFailed(eventContext, "expired");
|
|
104
126
|
throw new otp_errors_js_1.OTPExpiredError();
|
|
105
127
|
}
|
|
106
128
|
const isValid = (0, otp_hash_js_1.verifyOtpHash)(input.otp, normalizedInput, storedHash, this.options.hashing);
|
|
@@ -109,14 +131,117 @@ class OTPManager {
|
|
|
109
131
|
if (attempts >= this.options.maxAttempts) {
|
|
110
132
|
await this.options.store.del(otpKey);
|
|
111
133
|
await this.options.store.del(attemptsKey);
|
|
134
|
+
await this.emitLocked(eventContext);
|
|
112
135
|
throw new otp_errors_js_1.OTPMaxAttemptsExceededError();
|
|
113
136
|
}
|
|
137
|
+
await this.emitFailed(eventContext, "invalid", attempts);
|
|
114
138
|
throw new otp_errors_js_1.OTPInvalidError();
|
|
115
139
|
}
|
|
116
140
|
await this.options.store.del(otpKey);
|
|
117
141
|
await this.options.store.del(attemptsKey);
|
|
142
|
+
await this.emitVerified(eventContext);
|
|
118
143
|
return true;
|
|
119
144
|
}
|
|
145
|
+
buildEventContext(input, normalizedIdentifier) {
|
|
146
|
+
return {
|
|
147
|
+
type: input.type,
|
|
148
|
+
identifier: input.identifier,
|
|
149
|
+
normalizedIdentifier,
|
|
150
|
+
intent: input.intent ?? "default",
|
|
151
|
+
timestamp: new Date().toISOString(),
|
|
152
|
+
metadata: input.metadata,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
async emitGenerated(context) {
|
|
156
|
+
await this.dispatchHook("generated", {
|
|
157
|
+
...context,
|
|
158
|
+
expiresIn: this.options.ttl,
|
|
159
|
+
devMode: this.options.devMode,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async emitVerified(context) {
|
|
163
|
+
await this.dispatchHook("verified", context);
|
|
164
|
+
}
|
|
165
|
+
async emitFailed(context, reason, attemptsUsed) {
|
|
166
|
+
await this.dispatchHook("failed", {
|
|
167
|
+
...context,
|
|
168
|
+
reason,
|
|
169
|
+
attemptsUsed,
|
|
170
|
+
attemptsRemaining: attemptsUsed !== undefined ? Math.max(this.options.maxAttempts - attemptsUsed, 0) : undefined,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async emitLocked(context) {
|
|
174
|
+
await this.dispatchHook("locked", {
|
|
175
|
+
...context,
|
|
176
|
+
maxAttempts: this.options.maxAttempts,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async emitRateLimited(context) {
|
|
180
|
+
if (!this.options.rateLimit) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
await this.dispatchHook("rate_limited", {
|
|
184
|
+
...context,
|
|
185
|
+
window: this.options.rateLimit.window,
|
|
186
|
+
max: this.options.rateLimit.max,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
async emitCooldownBlocked(context) {
|
|
190
|
+
if (!this.options.resendCooldown) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await this.dispatchHook("cooldown_blocked", {
|
|
194
|
+
...context,
|
|
195
|
+
resendCooldown: this.options.resendCooldown,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
async dispatchHook(eventName, payload) {
|
|
199
|
+
const hook = this.getHook(eventName);
|
|
200
|
+
if (!hook) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const execute = async () => {
|
|
204
|
+
try {
|
|
205
|
+
await hook(payload);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (this.options.hooks?.onHookError) {
|
|
209
|
+
await this.options.hooks.onHookError(error, {
|
|
210
|
+
event: eventName,
|
|
211
|
+
payload,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (this.options.hooks?.throwOnError) {
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
if (this.options.hooks?.throwOnError) {
|
|
220
|
+
await execute();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
queueMicrotask(() => {
|
|
224
|
+
void execute();
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
getHook(eventName) {
|
|
228
|
+
switch (eventName) {
|
|
229
|
+
case "generated":
|
|
230
|
+
return this.options.hooks?.onGenerated;
|
|
231
|
+
case "verified":
|
|
232
|
+
return this.options.hooks?.onVerified;
|
|
233
|
+
case "failed":
|
|
234
|
+
return this.options.hooks?.onFailed;
|
|
235
|
+
case "locked":
|
|
236
|
+
return this.options.hooks?.onLocked;
|
|
237
|
+
case "rate_limited":
|
|
238
|
+
return this.options.hooks?.onRateLimited;
|
|
239
|
+
case "cooldown_blocked":
|
|
240
|
+
return this.options.hooks?.onCooldownBlocked;
|
|
241
|
+
default:
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
120
245
|
}
|
|
121
246
|
exports.OTPManager = OTPManager;
|
|
122
247
|
function validateManagerOptions(options) {
|
|
@@ -170,6 +295,24 @@ function validateManagerOptions(options) {
|
|
|
170
295
|
throw new TypeError("hashing.allowLegacyVerify must be a boolean.");
|
|
171
296
|
}
|
|
172
297
|
}
|
|
298
|
+
if (options.hooks) {
|
|
299
|
+
const hookEntries = [
|
|
300
|
+
options.hooks.onGenerated,
|
|
301
|
+
options.hooks.onVerified,
|
|
302
|
+
options.hooks.onFailed,
|
|
303
|
+
options.hooks.onLocked,
|
|
304
|
+
options.hooks.onRateLimited,
|
|
305
|
+
options.hooks.onCooldownBlocked,
|
|
306
|
+
options.hooks.onHookError,
|
|
307
|
+
];
|
|
308
|
+
if (hookEntries.some((hook) => hook !== undefined && typeof hook !== "function")) {
|
|
309
|
+
throw new TypeError("All hooks must be functions when provided.");
|
|
310
|
+
}
|
|
311
|
+
if (options.hooks.throwOnError !== undefined &&
|
|
312
|
+
typeof options.hooks.throwOnError !== "boolean") {
|
|
313
|
+
throw new TypeError("hooks.throwOnError must be a boolean.");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
173
316
|
}
|
|
174
317
|
function validatePayload(input) {
|
|
175
318
|
if (!input.type || !input.type.trim()) {
|
|
@@ -4,4 +4,13 @@ export declare class OTPManager {
|
|
|
4
4
|
constructor(options: OTPManagerOptions);
|
|
5
5
|
generate(input: GenerateOTPInput): Promise<GenerateOTPResult>;
|
|
6
6
|
verify(input: VerifyOTPInput): Promise<true>;
|
|
7
|
+
private buildEventContext;
|
|
8
|
+
private emitGenerated;
|
|
9
|
+
private emitVerified;
|
|
10
|
+
private emitFailed;
|
|
11
|
+
private emitLocked;
|
|
12
|
+
private emitRateLimited;
|
|
13
|
+
private emitCooldownBlocked;
|
|
14
|
+
private dispatchHook;
|
|
15
|
+
private getHook;
|
|
7
16
|
}
|
package/dist/core/otp.service.js
CHANGED
|
@@ -18,6 +18,7 @@ export class OTPManager {
|
|
|
18
18
|
async generate(input) {
|
|
19
19
|
validatePayload(input);
|
|
20
20
|
const normalizedInput = normalizePayloadIdentifier(input, this.options.identifierNormalization);
|
|
21
|
+
const eventContext = this.buildEventContext(input, normalizedInput.identifier);
|
|
21
22
|
const otpKey = buildOtpKey(normalizedInput);
|
|
22
23
|
const attemptsKey = buildAttemptsKey(normalizedInput);
|
|
23
24
|
const rateLimitKey = buildRateLimitKey(normalizedInput);
|
|
@@ -37,38 +38,54 @@ export class OTPManager {
|
|
|
37
38
|
resendCooldown: this.options.resendCooldown,
|
|
38
39
|
});
|
|
39
40
|
if (atomicResult === "cooldown") {
|
|
41
|
+
await this.emitCooldownBlocked(eventContext);
|
|
40
42
|
throw new OTPResendCooldownError();
|
|
41
43
|
}
|
|
42
44
|
if (atomicResult === "rate_limit") {
|
|
45
|
+
await this.emitRateLimited(eventContext);
|
|
43
46
|
throw new OTPRateLimitExceededError();
|
|
44
47
|
}
|
|
45
48
|
if (atomicResult === "ok") {
|
|
46
|
-
|
|
49
|
+
const result = {
|
|
47
50
|
expiresIn: this.options.ttl,
|
|
48
51
|
otp: this.options.devMode ? otp : undefined,
|
|
49
52
|
};
|
|
53
|
+
await this.emitGenerated(eventContext);
|
|
54
|
+
return result;
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
if (this.options.resendCooldown) {
|
|
53
58
|
const cooldownActive = await this.options.store.get(cooldownKey);
|
|
54
59
|
if (cooldownActive) {
|
|
60
|
+
await this.emitCooldownBlocked(eventContext);
|
|
55
61
|
throw new OTPResendCooldownError();
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
|
-
|
|
64
|
+
try {
|
|
65
|
+
await assertWithinRateLimit(this.options.store, rateLimitKey, this.options.rateLimit);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof OTPRateLimitExceededError) {
|
|
69
|
+
await this.emitRateLimited(eventContext);
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
59
73
|
await this.options.store.set(otpKey, storedHash, this.options.ttl);
|
|
60
74
|
await this.options.store.del(attemptsKey);
|
|
61
75
|
if (this.options.resendCooldown) {
|
|
62
76
|
await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
|
|
63
77
|
}
|
|
64
|
-
|
|
78
|
+
const result = {
|
|
65
79
|
expiresIn: this.options.ttl,
|
|
66
80
|
otp: this.options.devMode ? otp : undefined,
|
|
67
81
|
};
|
|
82
|
+
await this.emitGenerated(eventContext);
|
|
83
|
+
return result;
|
|
68
84
|
}
|
|
69
85
|
async verify(input) {
|
|
70
86
|
validatePayload(input);
|
|
71
87
|
const normalizedInput = normalizePayloadIdentifier(input, this.options.identifierNormalization);
|
|
88
|
+
const eventContext = this.buildEventContext(input, normalizedInput.identifier);
|
|
72
89
|
if (!input.otp || !input.otp.trim()) {
|
|
73
90
|
throw new TypeError("OTP must be a non-empty string.");
|
|
74
91
|
}
|
|
@@ -84,20 +101,25 @@ export class OTPManager {
|
|
|
84
101
|
maxAttempts: this.options.maxAttempts,
|
|
85
102
|
});
|
|
86
103
|
if (atomicResult === "verified") {
|
|
104
|
+
await this.emitVerified(eventContext);
|
|
87
105
|
return true;
|
|
88
106
|
}
|
|
89
107
|
if (atomicResult === "expired") {
|
|
108
|
+
await this.emitFailed(eventContext, "expired");
|
|
90
109
|
throw new OTPExpiredError();
|
|
91
110
|
}
|
|
92
111
|
if (atomicResult === "max_attempts") {
|
|
112
|
+
await this.emitLocked(eventContext);
|
|
93
113
|
throw new OTPMaxAttemptsExceededError();
|
|
94
114
|
}
|
|
95
115
|
if (atomicResult === "invalid") {
|
|
116
|
+
await this.emitFailed(eventContext, "invalid");
|
|
96
117
|
throw new OTPInvalidError();
|
|
97
118
|
}
|
|
98
119
|
}
|
|
99
120
|
const storedHash = await this.options.store.get(otpKey);
|
|
100
121
|
if (!storedHash) {
|
|
122
|
+
await this.emitFailed(eventContext, "expired");
|
|
101
123
|
throw new OTPExpiredError();
|
|
102
124
|
}
|
|
103
125
|
const isValid = verifyOtpHash(input.otp, normalizedInput, storedHash, this.options.hashing);
|
|
@@ -106,14 +128,117 @@ export class OTPManager {
|
|
|
106
128
|
if (attempts >= this.options.maxAttempts) {
|
|
107
129
|
await this.options.store.del(otpKey);
|
|
108
130
|
await this.options.store.del(attemptsKey);
|
|
131
|
+
await this.emitLocked(eventContext);
|
|
109
132
|
throw new OTPMaxAttemptsExceededError();
|
|
110
133
|
}
|
|
134
|
+
await this.emitFailed(eventContext, "invalid", attempts);
|
|
111
135
|
throw new OTPInvalidError();
|
|
112
136
|
}
|
|
113
137
|
await this.options.store.del(otpKey);
|
|
114
138
|
await this.options.store.del(attemptsKey);
|
|
139
|
+
await this.emitVerified(eventContext);
|
|
115
140
|
return true;
|
|
116
141
|
}
|
|
142
|
+
buildEventContext(input, normalizedIdentifier) {
|
|
143
|
+
return {
|
|
144
|
+
type: input.type,
|
|
145
|
+
identifier: input.identifier,
|
|
146
|
+
normalizedIdentifier,
|
|
147
|
+
intent: input.intent ?? "default",
|
|
148
|
+
timestamp: new Date().toISOString(),
|
|
149
|
+
metadata: input.metadata,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async emitGenerated(context) {
|
|
153
|
+
await this.dispatchHook("generated", {
|
|
154
|
+
...context,
|
|
155
|
+
expiresIn: this.options.ttl,
|
|
156
|
+
devMode: this.options.devMode,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async emitVerified(context) {
|
|
160
|
+
await this.dispatchHook("verified", context);
|
|
161
|
+
}
|
|
162
|
+
async emitFailed(context, reason, attemptsUsed) {
|
|
163
|
+
await this.dispatchHook("failed", {
|
|
164
|
+
...context,
|
|
165
|
+
reason,
|
|
166
|
+
attemptsUsed,
|
|
167
|
+
attemptsRemaining: attemptsUsed !== undefined ? Math.max(this.options.maxAttempts - attemptsUsed, 0) : undefined,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async emitLocked(context) {
|
|
171
|
+
await this.dispatchHook("locked", {
|
|
172
|
+
...context,
|
|
173
|
+
maxAttempts: this.options.maxAttempts,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
async emitRateLimited(context) {
|
|
177
|
+
if (!this.options.rateLimit) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await this.dispatchHook("rate_limited", {
|
|
181
|
+
...context,
|
|
182
|
+
window: this.options.rateLimit.window,
|
|
183
|
+
max: this.options.rateLimit.max,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async emitCooldownBlocked(context) {
|
|
187
|
+
if (!this.options.resendCooldown) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await this.dispatchHook("cooldown_blocked", {
|
|
191
|
+
...context,
|
|
192
|
+
resendCooldown: this.options.resendCooldown,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async dispatchHook(eventName, payload) {
|
|
196
|
+
const hook = this.getHook(eventName);
|
|
197
|
+
if (!hook) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const execute = async () => {
|
|
201
|
+
try {
|
|
202
|
+
await hook(payload);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (this.options.hooks?.onHookError) {
|
|
206
|
+
await this.options.hooks.onHookError(error, {
|
|
207
|
+
event: eventName,
|
|
208
|
+
payload,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (this.options.hooks?.throwOnError) {
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
if (this.options.hooks?.throwOnError) {
|
|
217
|
+
await execute();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
queueMicrotask(() => {
|
|
221
|
+
void execute();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
getHook(eventName) {
|
|
225
|
+
switch (eventName) {
|
|
226
|
+
case "generated":
|
|
227
|
+
return this.options.hooks?.onGenerated;
|
|
228
|
+
case "verified":
|
|
229
|
+
return this.options.hooks?.onVerified;
|
|
230
|
+
case "failed":
|
|
231
|
+
return this.options.hooks?.onFailed;
|
|
232
|
+
case "locked":
|
|
233
|
+
return this.options.hooks?.onLocked;
|
|
234
|
+
case "rate_limited":
|
|
235
|
+
return this.options.hooks?.onRateLimited;
|
|
236
|
+
case "cooldown_blocked":
|
|
237
|
+
return this.options.hooks?.onCooldownBlocked;
|
|
238
|
+
default:
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
117
242
|
}
|
|
118
243
|
function validateManagerOptions(options) {
|
|
119
244
|
if (!Number.isInteger(options.ttl) || options.ttl <= 0) {
|
|
@@ -166,6 +291,24 @@ function validateManagerOptions(options) {
|
|
|
166
291
|
throw new TypeError("hashing.allowLegacyVerify must be a boolean.");
|
|
167
292
|
}
|
|
168
293
|
}
|
|
294
|
+
if (options.hooks) {
|
|
295
|
+
const hookEntries = [
|
|
296
|
+
options.hooks.onGenerated,
|
|
297
|
+
options.hooks.onVerified,
|
|
298
|
+
options.hooks.onFailed,
|
|
299
|
+
options.hooks.onLocked,
|
|
300
|
+
options.hooks.onRateLimited,
|
|
301
|
+
options.hooks.onCooldownBlocked,
|
|
302
|
+
options.hooks.onHookError,
|
|
303
|
+
];
|
|
304
|
+
if (hookEntries.some((hook) => hook !== undefined && typeof hook !== "function")) {
|
|
305
|
+
throw new TypeError("All hooks must be functions when provided.");
|
|
306
|
+
}
|
|
307
|
+
if (options.hooks.throwOnError !== undefined &&
|
|
308
|
+
typeof options.hooks.throwOnError !== "boolean") {
|
|
309
|
+
throw new TypeError("hooks.throwOnError must be a boolean.");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
169
312
|
}
|
|
170
313
|
function validatePayload(input) {
|
|
171
314
|
if (!input.type || !input.type.trim()) {
|
package/dist/core/otp.types.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export type OTPChannel = "email" | "sms" | "token" | (string & {});
|
|
2
|
+
export type OTPMetadata = Record<string, unknown>;
|
|
2
3
|
export interface OTPPayload {
|
|
3
4
|
type: OTPChannel;
|
|
4
5
|
identifier: string;
|
|
5
6
|
intent?: string;
|
|
7
|
+
metadata?: OTPMetadata;
|
|
6
8
|
}
|
|
7
9
|
export interface GenerateOTPInput extends OTPPayload {
|
|
8
10
|
}
|
|
@@ -23,6 +25,49 @@ export interface OTPHashingOptions {
|
|
|
23
25
|
previousSecrets?: string[];
|
|
24
26
|
allowLegacyVerify?: boolean;
|
|
25
27
|
}
|
|
28
|
+
export interface OTPEventContext {
|
|
29
|
+
type: OTPChannel;
|
|
30
|
+
identifier: string;
|
|
31
|
+
normalizedIdentifier: string;
|
|
32
|
+
intent: string;
|
|
33
|
+
timestamp: string;
|
|
34
|
+
metadata?: OTPMetadata;
|
|
35
|
+
}
|
|
36
|
+
export interface OTPGeneratedEvent extends OTPEventContext {
|
|
37
|
+
expiresIn: number;
|
|
38
|
+
devMode: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface OTPVerifiedEvent extends OTPEventContext {
|
|
41
|
+
}
|
|
42
|
+
export interface OTPFailedEvent extends OTPEventContext {
|
|
43
|
+
reason: "invalid" | "expired";
|
|
44
|
+
attemptsUsed?: number;
|
|
45
|
+
attemptsRemaining?: number;
|
|
46
|
+
}
|
|
47
|
+
export interface OTPLockedEvent extends OTPEventContext {
|
|
48
|
+
maxAttempts: number;
|
|
49
|
+
}
|
|
50
|
+
export interface OTPRateLimitedEvent extends OTPEventContext {
|
|
51
|
+
window: number;
|
|
52
|
+
max: number;
|
|
53
|
+
}
|
|
54
|
+
export interface OTPCooldownBlockedEvent extends OTPEventContext {
|
|
55
|
+
resendCooldown: number;
|
|
56
|
+
}
|
|
57
|
+
export interface OTPHookErrorContext {
|
|
58
|
+
event: "generated" | "verified" | "failed" | "locked" | "rate_limited" | "cooldown_blocked";
|
|
59
|
+
payload: OTPGeneratedEvent | OTPVerifiedEvent | OTPFailedEvent | OTPLockedEvent | OTPRateLimitedEvent | OTPCooldownBlockedEvent;
|
|
60
|
+
}
|
|
61
|
+
export interface OTPHooks {
|
|
62
|
+
onGenerated?: (event: OTPGeneratedEvent) => void | Promise<void>;
|
|
63
|
+
onVerified?: (event: OTPVerifiedEvent) => void | Promise<void>;
|
|
64
|
+
onFailed?: (event: OTPFailedEvent) => void | Promise<void>;
|
|
65
|
+
onLocked?: (event: OTPLockedEvent) => void | Promise<void>;
|
|
66
|
+
onRateLimited?: (event: OTPRateLimitedEvent) => void | Promise<void>;
|
|
67
|
+
onCooldownBlocked?: (event: OTPCooldownBlockedEvent) => void | Promise<void>;
|
|
68
|
+
onHookError?: (error: unknown, context: OTPHookErrorContext) => void | Promise<void>;
|
|
69
|
+
throwOnError?: boolean;
|
|
70
|
+
}
|
|
26
71
|
export interface OTPManagerOptions {
|
|
27
72
|
store: StoreAdapter;
|
|
28
73
|
ttl: number;
|
|
@@ -33,6 +78,7 @@ export interface OTPManagerOptions {
|
|
|
33
78
|
resendCooldown?: number;
|
|
34
79
|
identifierNormalization?: IdentifierNormalizationConfig;
|
|
35
80
|
hashing?: OTPHashingOptions;
|
|
81
|
+
hooks?: OTPHooks;
|
|
36
82
|
}
|
|
37
83
|
export interface GenerateOTPResult {
|
|
38
84
|
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, OTPHashingOptions, OTPManagerOptions, OTPPayload, RateLimitConfig, StoreAdapter, VerifyOTPInput, } from "./core/otp.types.js";
|
|
4
|
+
export type { GenerateOTPInput, GenerateOTPResult, IdentifierNormalizationConfig, OTPChannel, OTPCooldownBlockedEvent, OTPEventContext, OTPFailedEvent, OTPGeneratedEvent, OTPHashingOptions, OTPHookErrorContext, OTPHooks, OTPLockedEvent, OTPManagerOptions, OTPMetadata, OTPPayload, OTPRateLimitedEvent, OTPVerifiedEvent, 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