otp-guard 0.1.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.
@@ -0,0 +1,259 @@
1
+ import { O as OtpStore } from './store-mOFmlT-6.cjs';
2
+ export { a as OtpRecord } from './store-mOFmlT-6.cjs';
3
+
4
+ /** Duration in milliseconds, or a short string: "30s", "5m", "2h", "1d". */
5
+ type Duration = number | `${number}${'s' | 'm' | 'h' | 'd'}`;
6
+ /**
7
+ * Character set for generated codes.
8
+ *
9
+ * - `numeric` — digits only. The right default for SMS.
10
+ * - `alphanumeric` — excludes I, L, O, U, 0, 1 to avoid transcription errors
11
+ * when a code is read aloud or copied by hand.
12
+ * - `base32` — RFC 4648 alphabet, for codes that feed other systems.
13
+ */
14
+ type Alphabet = 'numeric' | 'alphanumeric' | 'base32';
15
+ interface RateWindow {
16
+ count: number;
17
+ window: Duration;
18
+ }
19
+ type VerifyFailure = 'invalid' | 'expired' | 'locked' | 'bind_mismatch';
20
+ /** Metadata passed to observability hooks. */
21
+ interface HookEvent {
22
+ /**
23
+ * The raw identity. Treat as personal data: do not ship it to an APM or
24
+ * third-party log sink. Use `keyHash` for that.
25
+ */
26
+ key: string;
27
+ /** Stable, non-reversible identifier derived from key and purpose. Safe to log. */
28
+ keyHash: string;
29
+ purpose: string;
30
+ scope?: string;
31
+ }
32
+ /**
33
+ * Observability callbacks.
34
+ *
35
+ * Hooks are advisory. Exceptions thrown inside them are swallowed, so a
36
+ * broken metrics pipeline cannot break authentication.
37
+ */
38
+ interface Hooks {
39
+ onIssue?(e: HookEvent): void;
40
+ onRateLimited?(e: HookEvent & {
41
+ reason: 'cooldown' | 'rate_limited';
42
+ /** Which of the three limits rejected the request. */
43
+ limit: 'cooldown' | 'key' | 'scope';
44
+ }): void;
45
+ onVerifyFailure?(e: HookEvent & {
46
+ reason: VerifyFailure;
47
+ attempts: number;
48
+ }): void;
49
+ onLockout?(e: HookEvent): void;
50
+ onStoreError?(e: {
51
+ operation: string;
52
+ error: unknown;
53
+ }): void;
54
+ }
55
+ interface OtpKitOptions {
56
+ store: OtpStore;
57
+ /**
58
+ * Server-side secret mixed into every hash and every store key. Rotating it
59
+ * invalidates all outstanding codes, which is a legitimate emergency lever.
60
+ *
61
+ * Minimum 16 characters. Generate with:
62
+ * `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
63
+ *
64
+ * Keep it out of the database the hashes live in — that separation is the
65
+ * entire point of a pepper.
66
+ */
67
+ pepper: string;
68
+ /** Characters per code. 4–32. Default 6. */
69
+ length?: number;
70
+ /** Character set. Default `"numeric"`. */
71
+ alphabet?: Alphabet;
72
+ /** How long a code stays valid. Default `"5m"`. */
73
+ ttl?: Duration;
74
+ /** Failed attempts before the key is locked. Default 5. */
75
+ maxAttempts?: number;
76
+ /**
77
+ * How long a lockout persists. Independent of `ttl` on purpose: if the
78
+ * attempt counter expired alongside the code, a resend would refresh the
79
+ * attacker's guess budget and `maxAttempts` would be decorative.
80
+ * Default `"15m"`.
81
+ */
82
+ lockoutWindow?: Duration;
83
+ /**
84
+ * How long an expired record is retained past `ttl` so that `verify` can
85
+ * answer `"expired"` rather than `"invalid"`.
86
+ *
87
+ * Without this the record dies with its TTL and an expired code is
88
+ * indistinguishable from a wrong one, which denies the user the only
89
+ * message that tells them what to do ("request a new code"). Retention is
90
+ * safe: validity is decided by `expiresAt`, not by the record's existence.
91
+ *
92
+ * Set to a small value to reduce storage. Default `"10m"`.
93
+ */
94
+ expiredGrace?: Duration;
95
+ /** Minimum gap between issues for the same key. Default `"60s"`. */
96
+ resendCooldown?: Duration;
97
+ /** Cap on issues per key per window. Default 5 per hour. */
98
+ maxSendsPerKey?: RateWindow;
99
+ /**
100
+ * Cap on issues per caller-supplied scope (IP, device id, tenant).
101
+ * This is the limit that stops OTP-pumping fraud, where an attacker cycles
102
+ * through numbers to burn your SMS credit. Requires `scope` on `issue`.
103
+ * Default 20 per hour.
104
+ */
105
+ maxSendsPerScope?: RateWindow;
106
+ /** Reject `key`, `purpose`, `scope`, or `bindToken` longer than this. Default 256. */
107
+ maxInputLength?: number;
108
+ /** Namespace prefix for all store keys. Default `"otp"`. */
109
+ prefix?: string;
110
+ /** Observability callbacks. */
111
+ hooks?: Hooks;
112
+ /** Injectable clock. Intended for tests. */
113
+ now?: () => number;
114
+ }
115
+ interface IssueInput {
116
+ /** Identity being verified — phone number, email, user id. */
117
+ key: string;
118
+ /**
119
+ * Purpose scope. A code issued for one purpose will not verify another,
120
+ * so a phished login code cannot be replayed against a payment.
121
+ * Default `"default"`.
122
+ */
123
+ purpose?: string;
124
+ /** Rate-limit scope, typically `req.ip`. Enables `maxSendsPerScope`. */
125
+ scope?: string;
126
+ /**
127
+ * Binds the code to a session or device. The same value must be presented
128
+ * at verify, which narrows the window for an attacker who intercepts the
129
+ * code but not the session.
130
+ */
131
+ bindToken?: string;
132
+ }
133
+ type IssueResult = {
134
+ ok: true;
135
+ /**
136
+ * Plaintext code. Returned once and never stored. Send it; do not log
137
+ * it, and do not put it in an error message.
138
+ */
139
+ code: string;
140
+ expiresAt: number;
141
+ /** Epoch millis before which a resend will be refused. */
142
+ resendAfter: number;
143
+ } | {
144
+ ok: false;
145
+ reason: 'cooldown' | 'rate_limited';
146
+ /** Epoch millis at which the caller may retry. */
147
+ retryAfter: number;
148
+ };
149
+ interface VerifyInput {
150
+ key: string;
151
+ code: string;
152
+ /** Must match the purpose the code was issued under. */
153
+ purpose?: string;
154
+ /** Required if the code was issued with a `bindToken`. */
155
+ bindToken?: string;
156
+ }
157
+ type VerifyResult = {
158
+ ok: true;
159
+ } | {
160
+ ok: false;
161
+ reason: VerifyFailure;
162
+ /** Attempts remaining before lockout, when knowable. */
163
+ attemptsLeft?: number;
164
+ };
165
+ interface OtpKit {
166
+ issue(input: IssueInput): Promise<IssueResult>;
167
+ verify(input: VerifyInput): Promise<VerifyResult>;
168
+ /** Clear the outstanding code, attempt counter, and lockout for a key. */
169
+ reset(key: string, purpose?: string): Promise<void>;
170
+ /** Release store-owned resources. */
171
+ close(): Promise<void>;
172
+ }
173
+
174
+ /**
175
+ * Create an OTP issuer/verifier.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * import { createOtpKit, memoryStore } from 'otp-guard';
180
+ *
181
+ * const otp = createOtpKit({
182
+ * store: memoryStore(),
183
+ * pepper: process.env.OTP_PEPPER!,
184
+ * ttl: '5m',
185
+ * maxAttempts: 5,
186
+ * });
187
+ *
188
+ * const issued = await otp.issue({ key: '+919812345678', purpose: 'login' });
189
+ * if (issued.ok) await sms.send(issued.code);
190
+ *
191
+ * const result = await otp.verify({
192
+ * key: '+919812345678',
193
+ * code: '123456',
194
+ * purpose: 'login',
195
+ * });
196
+ * ```
197
+ *
198
+ * @throws {OtpConfigError} if the options are invalid.
199
+ */
200
+ declare function createOtpKit(options: OtpKitOptions): OtpKit;
201
+
202
+ interface MemoryStoreOptions {
203
+ /** Injectable clock. Intended for tests. */
204
+ now?: () => number;
205
+ /**
206
+ * Hard cap on retained entries. When exceeded, the soonest-expiring entries
207
+ * are dropped first. Prevents unbounded growth from codes that are issued
208
+ * and never verified. Default 100_000.
209
+ */
210
+ maxEntries?: number;
211
+ /** Sweep interval for expired entries in ms. Default 60_000. 0 disables. */
212
+ sweepIntervalMs?: number;
213
+ }
214
+ /**
215
+ * Process-local store.
216
+ *
217
+ * Suitable for tests, CLIs, and single-instance apps. NOT suitable behind a
218
+ * load balancer: counters live in one process's heap, so N instances means N
219
+ * times your intended rate limit.
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * const otp = createOtpKit({ store: memoryStore(), pepper });
224
+ * ```
225
+ */
226
+ declare function memoryStore(opts?: MemoryStoreOptions): OtpStore;
227
+
228
+ /**
229
+ * Thrown when the underlying store fails.
230
+ *
231
+ * otp-guard fails closed: a store outage means codes cannot be issued or
232
+ * verified, never that verification silently succeeds. Catch this at your
233
+ * route layer and return 503.
234
+ *
235
+ * @example
236
+ * ```ts
237
+ * try {
238
+ * await otp.verify({ key, code });
239
+ * } catch (err) {
240
+ * if (err instanceof OtpStoreError) return reply.status(503).send();
241
+ * throw err;
242
+ * }
243
+ * ```
244
+ */
245
+ declare class OtpStoreError extends Error {
246
+ /** Which store operation failed, e.g. "acquire_cooldown". */
247
+ readonly operation: string;
248
+ constructor(operation: string, cause: unknown);
249
+ }
250
+ /**
251
+ * Thrown for caller mistakes: invalid configuration, malformed or oversized
252
+ * identifiers. Always a programming error, never a runtime condition to
253
+ * handle gracefully.
254
+ */
255
+ declare class OtpConfigError extends Error {
256
+ constructor(message: string);
257
+ }
258
+
259
+ export { type Alphabet, type Duration, type HookEvent, type Hooks, type IssueInput, type IssueResult, type MemoryStoreOptions, OtpConfigError, type OtpKit, type OtpKitOptions, OtpStore, OtpStoreError, type RateWindow, type VerifyFailure, type VerifyInput, type VerifyResult, createOtpKit, memoryStore };
@@ -0,0 +1,259 @@
1
+ import { O as OtpStore } from './store-mOFmlT-6.js';
2
+ export { a as OtpRecord } from './store-mOFmlT-6.js';
3
+
4
+ /** Duration in milliseconds, or a short string: "30s", "5m", "2h", "1d". */
5
+ type Duration = number | `${number}${'s' | 'm' | 'h' | 'd'}`;
6
+ /**
7
+ * Character set for generated codes.
8
+ *
9
+ * - `numeric` — digits only. The right default for SMS.
10
+ * - `alphanumeric` — excludes I, L, O, U, 0, 1 to avoid transcription errors
11
+ * when a code is read aloud or copied by hand.
12
+ * - `base32` — RFC 4648 alphabet, for codes that feed other systems.
13
+ */
14
+ type Alphabet = 'numeric' | 'alphanumeric' | 'base32';
15
+ interface RateWindow {
16
+ count: number;
17
+ window: Duration;
18
+ }
19
+ type VerifyFailure = 'invalid' | 'expired' | 'locked' | 'bind_mismatch';
20
+ /** Metadata passed to observability hooks. */
21
+ interface HookEvent {
22
+ /**
23
+ * The raw identity. Treat as personal data: do not ship it to an APM or
24
+ * third-party log sink. Use `keyHash` for that.
25
+ */
26
+ key: string;
27
+ /** Stable, non-reversible identifier derived from key and purpose. Safe to log. */
28
+ keyHash: string;
29
+ purpose: string;
30
+ scope?: string;
31
+ }
32
+ /**
33
+ * Observability callbacks.
34
+ *
35
+ * Hooks are advisory. Exceptions thrown inside them are swallowed, so a
36
+ * broken metrics pipeline cannot break authentication.
37
+ */
38
+ interface Hooks {
39
+ onIssue?(e: HookEvent): void;
40
+ onRateLimited?(e: HookEvent & {
41
+ reason: 'cooldown' | 'rate_limited';
42
+ /** Which of the three limits rejected the request. */
43
+ limit: 'cooldown' | 'key' | 'scope';
44
+ }): void;
45
+ onVerifyFailure?(e: HookEvent & {
46
+ reason: VerifyFailure;
47
+ attempts: number;
48
+ }): void;
49
+ onLockout?(e: HookEvent): void;
50
+ onStoreError?(e: {
51
+ operation: string;
52
+ error: unknown;
53
+ }): void;
54
+ }
55
+ interface OtpKitOptions {
56
+ store: OtpStore;
57
+ /**
58
+ * Server-side secret mixed into every hash and every store key. Rotating it
59
+ * invalidates all outstanding codes, which is a legitimate emergency lever.
60
+ *
61
+ * Minimum 16 characters. Generate with:
62
+ * `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
63
+ *
64
+ * Keep it out of the database the hashes live in — that separation is the
65
+ * entire point of a pepper.
66
+ */
67
+ pepper: string;
68
+ /** Characters per code. 4–32. Default 6. */
69
+ length?: number;
70
+ /** Character set. Default `"numeric"`. */
71
+ alphabet?: Alphabet;
72
+ /** How long a code stays valid. Default `"5m"`. */
73
+ ttl?: Duration;
74
+ /** Failed attempts before the key is locked. Default 5. */
75
+ maxAttempts?: number;
76
+ /**
77
+ * How long a lockout persists. Independent of `ttl` on purpose: if the
78
+ * attempt counter expired alongside the code, a resend would refresh the
79
+ * attacker's guess budget and `maxAttempts` would be decorative.
80
+ * Default `"15m"`.
81
+ */
82
+ lockoutWindow?: Duration;
83
+ /**
84
+ * How long an expired record is retained past `ttl` so that `verify` can
85
+ * answer `"expired"` rather than `"invalid"`.
86
+ *
87
+ * Without this the record dies with its TTL and an expired code is
88
+ * indistinguishable from a wrong one, which denies the user the only
89
+ * message that tells them what to do ("request a new code"). Retention is
90
+ * safe: validity is decided by `expiresAt`, not by the record's existence.
91
+ *
92
+ * Set to a small value to reduce storage. Default `"10m"`.
93
+ */
94
+ expiredGrace?: Duration;
95
+ /** Minimum gap between issues for the same key. Default `"60s"`. */
96
+ resendCooldown?: Duration;
97
+ /** Cap on issues per key per window. Default 5 per hour. */
98
+ maxSendsPerKey?: RateWindow;
99
+ /**
100
+ * Cap on issues per caller-supplied scope (IP, device id, tenant).
101
+ * This is the limit that stops OTP-pumping fraud, where an attacker cycles
102
+ * through numbers to burn your SMS credit. Requires `scope` on `issue`.
103
+ * Default 20 per hour.
104
+ */
105
+ maxSendsPerScope?: RateWindow;
106
+ /** Reject `key`, `purpose`, `scope`, or `bindToken` longer than this. Default 256. */
107
+ maxInputLength?: number;
108
+ /** Namespace prefix for all store keys. Default `"otp"`. */
109
+ prefix?: string;
110
+ /** Observability callbacks. */
111
+ hooks?: Hooks;
112
+ /** Injectable clock. Intended for tests. */
113
+ now?: () => number;
114
+ }
115
+ interface IssueInput {
116
+ /** Identity being verified — phone number, email, user id. */
117
+ key: string;
118
+ /**
119
+ * Purpose scope. A code issued for one purpose will not verify another,
120
+ * so a phished login code cannot be replayed against a payment.
121
+ * Default `"default"`.
122
+ */
123
+ purpose?: string;
124
+ /** Rate-limit scope, typically `req.ip`. Enables `maxSendsPerScope`. */
125
+ scope?: string;
126
+ /**
127
+ * Binds the code to a session or device. The same value must be presented
128
+ * at verify, which narrows the window for an attacker who intercepts the
129
+ * code but not the session.
130
+ */
131
+ bindToken?: string;
132
+ }
133
+ type IssueResult = {
134
+ ok: true;
135
+ /**
136
+ * Plaintext code. Returned once and never stored. Send it; do not log
137
+ * it, and do not put it in an error message.
138
+ */
139
+ code: string;
140
+ expiresAt: number;
141
+ /** Epoch millis before which a resend will be refused. */
142
+ resendAfter: number;
143
+ } | {
144
+ ok: false;
145
+ reason: 'cooldown' | 'rate_limited';
146
+ /** Epoch millis at which the caller may retry. */
147
+ retryAfter: number;
148
+ };
149
+ interface VerifyInput {
150
+ key: string;
151
+ code: string;
152
+ /** Must match the purpose the code was issued under. */
153
+ purpose?: string;
154
+ /** Required if the code was issued with a `bindToken`. */
155
+ bindToken?: string;
156
+ }
157
+ type VerifyResult = {
158
+ ok: true;
159
+ } | {
160
+ ok: false;
161
+ reason: VerifyFailure;
162
+ /** Attempts remaining before lockout, when knowable. */
163
+ attemptsLeft?: number;
164
+ };
165
+ interface OtpKit {
166
+ issue(input: IssueInput): Promise<IssueResult>;
167
+ verify(input: VerifyInput): Promise<VerifyResult>;
168
+ /** Clear the outstanding code, attempt counter, and lockout for a key. */
169
+ reset(key: string, purpose?: string): Promise<void>;
170
+ /** Release store-owned resources. */
171
+ close(): Promise<void>;
172
+ }
173
+
174
+ /**
175
+ * Create an OTP issuer/verifier.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * import { createOtpKit, memoryStore } from 'otp-guard';
180
+ *
181
+ * const otp = createOtpKit({
182
+ * store: memoryStore(),
183
+ * pepper: process.env.OTP_PEPPER!,
184
+ * ttl: '5m',
185
+ * maxAttempts: 5,
186
+ * });
187
+ *
188
+ * const issued = await otp.issue({ key: '+919812345678', purpose: 'login' });
189
+ * if (issued.ok) await sms.send(issued.code);
190
+ *
191
+ * const result = await otp.verify({
192
+ * key: '+919812345678',
193
+ * code: '123456',
194
+ * purpose: 'login',
195
+ * });
196
+ * ```
197
+ *
198
+ * @throws {OtpConfigError} if the options are invalid.
199
+ */
200
+ declare function createOtpKit(options: OtpKitOptions): OtpKit;
201
+
202
+ interface MemoryStoreOptions {
203
+ /** Injectable clock. Intended for tests. */
204
+ now?: () => number;
205
+ /**
206
+ * Hard cap on retained entries. When exceeded, the soonest-expiring entries
207
+ * are dropped first. Prevents unbounded growth from codes that are issued
208
+ * and never verified. Default 100_000.
209
+ */
210
+ maxEntries?: number;
211
+ /** Sweep interval for expired entries in ms. Default 60_000. 0 disables. */
212
+ sweepIntervalMs?: number;
213
+ }
214
+ /**
215
+ * Process-local store.
216
+ *
217
+ * Suitable for tests, CLIs, and single-instance apps. NOT suitable behind a
218
+ * load balancer: counters live in one process's heap, so N instances means N
219
+ * times your intended rate limit.
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * const otp = createOtpKit({ store: memoryStore(), pepper });
224
+ * ```
225
+ */
226
+ declare function memoryStore(opts?: MemoryStoreOptions): OtpStore;
227
+
228
+ /**
229
+ * Thrown when the underlying store fails.
230
+ *
231
+ * otp-guard fails closed: a store outage means codes cannot be issued or
232
+ * verified, never that verification silently succeeds. Catch this at your
233
+ * route layer and return 503.
234
+ *
235
+ * @example
236
+ * ```ts
237
+ * try {
238
+ * await otp.verify({ key, code });
239
+ * } catch (err) {
240
+ * if (err instanceof OtpStoreError) return reply.status(503).send();
241
+ * throw err;
242
+ * }
243
+ * ```
244
+ */
245
+ declare class OtpStoreError extends Error {
246
+ /** Which store operation failed, e.g. "acquire_cooldown". */
247
+ readonly operation: string;
248
+ constructor(operation: string, cause: unknown);
249
+ }
250
+ /**
251
+ * Thrown for caller mistakes: invalid configuration, malformed or oversized
252
+ * identifiers. Always a programming error, never a runtime condition to
253
+ * handle gracefully.
254
+ */
255
+ declare class OtpConfigError extends Error {
256
+ constructor(message: string);
257
+ }
258
+
259
+ export { type Alphabet, type Duration, type HookEvent, type Hooks, type IssueInput, type IssueResult, type MemoryStoreOptions, OtpConfigError, type OtpKit, type OtpKitOptions, OtpStore, OtpStoreError, type RateWindow, type VerifyFailure, type VerifyInput, type VerifyResult, createOtpKit, memoryStore };