@c9up/warden 0.1.4 → 0.1.5

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.
Files changed (74) hide show
  1. package/README.md +57 -0
  2. package/dist/Guard.d.ts +9 -0
  3. package/dist/Guard.d.ts.map +1 -1
  4. package/dist/Guard.js +16 -0
  5. package/dist/Guard.js.map +1 -1
  6. package/dist/RedisBlacklistDriver.d.ts +32 -0
  7. package/dist/RedisBlacklistDriver.d.ts.map +1 -0
  8. package/dist/RedisBlacklistDriver.js +37 -0
  9. package/dist/RedisBlacklistDriver.js.map +1 -0
  10. package/dist/ResilientBlacklistDriver.d.ts +35 -0
  11. package/dist/ResilientBlacklistDriver.d.ts.map +1 -0
  12. package/dist/ResilientBlacklistDriver.js +61 -0
  13. package/dist/ResilientBlacklistDriver.js.map +1 -0
  14. package/dist/WardenProvider.d.ts.map +1 -1
  15. package/dist/WardenProvider.js +10 -0
  16. package/dist/WardenProvider.js.map +1 -1
  17. package/dist/config.d.ts +9 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js.map +1 -1
  20. package/dist/index.d.ts +15 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +8 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/mfa/BackupCodesProvider.d.ts +39 -0
  25. package/dist/mfa/BackupCodesProvider.d.ts.map +1 -0
  26. package/dist/mfa/BackupCodesProvider.js +87 -0
  27. package/dist/mfa/BackupCodesProvider.js.map +1 -0
  28. package/dist/mfa/MfaManager.d.ts +107 -0
  29. package/dist/mfa/MfaManager.d.ts.map +1 -0
  30. package/dist/mfa/MfaManager.js +219 -0
  31. package/dist/mfa/MfaManager.js.map +1 -0
  32. package/dist/mfa/OtpProvider.d.ts +67 -0
  33. package/dist/mfa/OtpProvider.d.ts.map +1 -0
  34. package/dist/mfa/OtpProvider.js +104 -0
  35. package/dist/mfa/OtpProvider.js.map +1 -0
  36. package/dist/mfa/TotpProvider.d.ts +52 -0
  37. package/dist/mfa/TotpProvider.d.ts.map +1 -0
  38. package/dist/mfa/TotpProvider.js +103 -0
  39. package/dist/mfa/TotpProvider.js.map +1 -0
  40. package/dist/mfa/WebauthnProvider.d.ts +175 -0
  41. package/dist/mfa/WebauthnProvider.d.ts.map +1 -0
  42. package/dist/mfa/WebauthnProvider.js +239 -0
  43. package/dist/mfa/WebauthnProvider.js.map +1 -0
  44. package/dist/mfa/base32.d.ts +13 -0
  45. package/dist/mfa/base32.d.ts.map +1 -0
  46. package/dist/mfa/base32.js +52 -0
  47. package/dist/mfa/base32.js.map +1 -0
  48. package/dist/mfa/webauthn-codec.d.ts +58 -0
  49. package/dist/mfa/webauthn-codec.d.ts.map +1 -0
  50. package/dist/mfa/webauthn-codec.js +221 -0
  51. package/dist/mfa/webauthn-codec.js.map +1 -0
  52. package/dist/middleware.d.ts.map +1 -1
  53. package/dist/middleware.js +13 -1
  54. package/dist/middleware.js.map +1 -1
  55. package/index.darwin-arm64.node +0 -0
  56. package/index.darwin-x64.node +0 -0
  57. package/index.linux-arm64-gnu.node +0 -0
  58. package/index.linux-x64-gnu.node +0 -0
  59. package/index.win32-x64-msvc.node +0 -0
  60. package/package.json +1 -1
  61. package/src/Guard.ts +21 -0
  62. package/src/RedisBlacklistDriver.ts +60 -0
  63. package/src/ResilientBlacklistDriver.ts +84 -0
  64. package/src/WardenProvider.ts +11 -0
  65. package/src/config.ts +7 -0
  66. package/src/index.ts +58 -0
  67. package/src/mfa/BackupCodesProvider.ts +125 -0
  68. package/src/mfa/MfaManager.ts +307 -0
  69. package/src/mfa/OtpProvider.ts +177 -0
  70. package/src/mfa/TotpProvider.ts +140 -0
  71. package/src/mfa/WebauthnProvider.ts +416 -0
  72. package/src/mfa/base32.ts +54 -0
  73. package/src/mfa/webauthn-codec.ts +264 -0
  74. package/src/middleware.ts +14 -0
package/src/index.ts CHANGED
@@ -44,10 +44,63 @@ export {
44
44
  Guard,
45
45
  getGuardMetadata,
46
46
  getPermissionMetadata,
47
+ getRequireMfaMetadata,
47
48
  getRoleMetadata,
48
49
  Permission,
50
+ RequireMfa,
49
51
  Role,
50
52
  } from "./Guard.js";
53
+ export type {
54
+ BackupCodesConfig,
55
+ BackupCodesResult,
56
+ BackupCodeVerification,
57
+ } from "./mfa/BackupCodesProvider.js";
58
+ export { BackupCodesProvider } from "./mfa/BackupCodesProvider.js";
59
+ export type {
60
+ MfaFactor,
61
+ MfaFactorKind,
62
+ MfaFactorStore,
63
+ MfaFactorSummary,
64
+ MfaManagerConfig,
65
+ } from "./mfa/MfaManager.js";
66
+ export { MemoryMfaFactorStore, MfaManager } from "./mfa/MfaManager.js";
67
+ export type {
68
+ OtpChallenge,
69
+ OtpChallengeStore,
70
+ OtpConfig,
71
+ OtpDeliveryChannel,
72
+ OtpFailureReason,
73
+ OtpStartResult,
74
+ OtpVerification,
75
+ } from "./mfa/OtpProvider.js";
76
+ export { MemoryOtpChallengeStore, OtpProvider } from "./mfa/OtpProvider.js";
77
+ export type {
78
+ TotpAlgorithm,
79
+ TotpConfig,
80
+ TotpEnrollment,
81
+ } from "./mfa/TotpProvider.js";
82
+ export { TotpProvider } from "./mfa/TotpProvider.js";
83
+ export type {
84
+ AuthenticationOptionsJSON,
85
+ AuthenticationResponseJSON,
86
+ RegistrationOptionsJSON,
87
+ RegistrationResponseJSON,
88
+ StoredPasskey,
89
+ WebauthnChallengeStore,
90
+ WebauthnConfig,
91
+ WebauthnCredentialStore,
92
+ WebauthnUser,
93
+ } from "./mfa/WebauthnProvider.js";
94
+ export {
95
+ MemoryWebauthnChallengeStore,
96
+ MemoryWebauthnCredentialStore,
97
+ WebauthnProvider,
98
+ } from "./mfa/WebauthnProvider.js";
99
+ export type {
100
+ RedisBlacklistConfig,
101
+ RedisLikeClient,
102
+ } from "./RedisBlacklistDriver.js";
103
+ export { RedisBlacklistDriver } from "./RedisBlacklistDriver.js";
51
104
  export type {
52
105
  RefreshTokenDriver,
53
106
  StoredRefreshToken,
@@ -56,6 +109,11 @@ export {
56
109
  generateRefreshToken,
57
110
  MemoryRefreshTokenDriver,
58
111
  } from "./RefreshTokenStore.js";
112
+ export type {
113
+ BlacklistDegradeEvent,
114
+ ResilientBlacklistConfig,
115
+ } from "./ResilientBlacklistDriver.js";
116
+ export { ResilientBlacklistDriver } from "./ResilientBlacklistDriver.js";
59
117
  export { MemoryRightsStore } from "./rights/MemoryRightsStore.js";
60
118
  export { RightsResolver } from "./rights/RightsResolver.js";
61
119
  export type {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * BackupCodesProvider — single-use recovery codes for when a user loses their
3
+ * authenticator. Codes are shown to the user exactly once at generation time;
4
+ * only salted hashes are persisted. Verifying a code consumes it.
5
+ *
6
+ * Codes are high-entropy random tokens (default ~50 bits), so a single salted
7
+ * SHA-256 is the right primitive here — not an expensive password KDF.
8
+ */
9
+
10
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
11
+ import { WardenError } from "../errors.js";
12
+ import { base32Encode } from "./base32.js";
13
+
14
+ export interface BackupCodesConfig {
15
+ /** How many codes to generate. Default `10`. */
16
+ count?: number;
17
+ /** Characters per code (base32, excludes the separator). Default `10`. */
18
+ length?: number;
19
+ }
20
+
21
+ export interface BackupCodesResult {
22
+ /** Plaintext codes — display ONCE, never persist. */
23
+ codes: string[];
24
+ /** Salted hashes (`salt$digest`) to persist against the user. */
25
+ hashes: string[];
26
+ }
27
+
28
+ export interface BackupCodeVerification {
29
+ ok: boolean;
30
+ /** The hash list with the consumed code removed (persist this on success). */
31
+ remaining: string[];
32
+ }
33
+
34
+ const DEFAULTS = { count: 10, length: 10 };
35
+
36
+ export class BackupCodesProvider {
37
+ readonly kind = "backup_codes" as const;
38
+ readonly #cfg: Required<BackupCodesConfig>;
39
+
40
+ constructor(config: BackupCodesConfig = {}) {
41
+ this.#cfg = { ...DEFAULTS, ...config };
42
+ if (this.#cfg.count < 1) {
43
+ throw new WardenError(
44
+ "INVALID_CONFIG",
45
+ `backup code count must be >= 1, got ${this.#cfg.count}`,
46
+ );
47
+ }
48
+ if (this.#cfg.length < 8) {
49
+ throw new WardenError(
50
+ "INVALID_CONFIG",
51
+ `backup code length must be >= 8 for adequate entropy, got ${this.#cfg.length}`,
52
+ );
53
+ }
54
+ }
55
+
56
+ /** Generate a fresh batch of codes plus the hashes to persist. */
57
+ generate(): BackupCodesResult {
58
+ const codes: string[] = [];
59
+ const hashes: string[] = [];
60
+ for (let i = 0; i < this.#cfg.count; i++) {
61
+ const raw = base32Encode(randomBytes(this.#cfg.length)).slice(
62
+ 0,
63
+ this.#cfg.length,
64
+ );
65
+ // Split in the middle for readability: "ABCDE-FGHIJ".
66
+ const half = Math.floor(this.#cfg.length / 2);
67
+ codes.push(`${raw.slice(0, half)}-${raw.slice(half)}`);
68
+ hashes.push(saltedHash(raw));
69
+ }
70
+ return { codes, hashes };
71
+ }
72
+
73
+ /**
74
+ * Verify a user-supplied code against the stored hashes. On a match the
75
+ * code is consumed: `remaining` excludes it and must be persisted. Input is
76
+ * normalized (separators stripped, upper-cased) before comparison.
77
+ */
78
+ verify(hashes: string[], code: string): BackupCodeVerification {
79
+ const candidate = normalize(code);
80
+ let matchedIndex = -1;
81
+ // Scan every entry (no early exit) so timing doesn't leak position.
82
+ for (let i = 0; i < hashes.length; i++) {
83
+ if (matchesStored(hashes[i], candidate)) {
84
+ matchedIndex = i;
85
+ }
86
+ }
87
+ if (matchedIndex === -1) {
88
+ return { ok: false, remaining: hashes };
89
+ }
90
+ return {
91
+ ok: true,
92
+ remaining: hashes.filter((_, i) => i !== matchedIndex),
93
+ };
94
+ }
95
+ }
96
+
97
+ function normalize(code: string): string {
98
+ return code.replace(/[\s-]/g, "").toUpperCase();
99
+ }
100
+
101
+ /** Hash a normalized code under a fresh random salt → `salt$digest` (hex). */
102
+ function saltedHash(normalized: string): string {
103
+ const salt = randomBytes(8);
104
+ const digest = digestWith(salt, normalized);
105
+ return `${salt.toString("hex")}$${digest.toString("hex")}`;
106
+ }
107
+
108
+ /** Re-hash the candidate under the stored entry's salt and compare. */
109
+ function matchesStored(stored: string, candidate: string): boolean {
110
+ const sep = stored.indexOf("$");
111
+ if (sep === -1) {
112
+ return false;
113
+ }
114
+ const salt = Buffer.from(stored.slice(0, sep), "hex");
115
+ const expected = Buffer.from(stored.slice(sep + 1), "hex");
116
+ const actual = digestWith(salt, candidate);
117
+ if (actual.length !== expected.length) {
118
+ return false;
119
+ }
120
+ return timingSafeEqual(actual, expected);
121
+ }
122
+
123
+ function digestWith(salt: Buffer, normalized: string): Buffer {
124
+ return createHash("sha256").update(salt).update(normalized).digest();
125
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * MfaManager — orchestrates the stateful "enable 2FA" lifecycle for a user:
3
+ * TOTP enrollment (pending → confirmed) and single-use backup codes, over a
4
+ * pluggable factor store (in-memory default).
5
+ *
6
+ * OTP (email/SMS) and WebAuthn are NOT folded in here: OTP is ephemeral
7
+ * (challenge/response, no enrollment) and WebAuthn already persists its own
8
+ * passkeys in its credential store. Use `OtpProvider` / `WebauthnProvider`
9
+ * directly for those, alongside this manager.
10
+ */
11
+
12
+ import { randomBytes } from "node:crypto";
13
+ import { WardenError } from "../errors.js";
14
+ import type { BackupCodesProvider } from "./BackupCodesProvider.js";
15
+ import type { TotpProvider } from "./TotpProvider.js";
16
+
17
+ export type MfaFactorKind = "totp" | "backup_codes";
18
+
19
+ export interface MfaFactor {
20
+ id: string;
21
+ userId: string;
22
+ kind: MfaFactorKind;
23
+ label?: string;
24
+ /** TOTP shared secret (base32), present for `totp` factors. */
25
+ secret?: string;
26
+ /** Salted hashes of the remaining backup codes, for `backup_codes` factors. */
27
+ backupHashes?: string[];
28
+ /** Epoch ms when the factor became usable; absent while pending. */
29
+ confirmedAt?: number;
30
+ createdAt: number;
31
+ }
32
+
33
+ /** Public, secret-free view of an enrolled factor. */
34
+ export interface MfaFactorSummary {
35
+ id: string;
36
+ kind: MfaFactorKind;
37
+ label?: string;
38
+ confirmed: boolean;
39
+ }
40
+
41
+ export interface MfaFactorStore {
42
+ save(factor: MfaFactor): Promise<void>;
43
+ findById(id: string): Promise<MfaFactor | null>;
44
+ findByUser(userId: string): Promise<MfaFactor[]>;
45
+ delete(id: string): Promise<void>;
46
+ }
47
+
48
+ export class MemoryMfaFactorStore implements MfaFactorStore {
49
+ #store = new Map<string, MfaFactor>();
50
+ async save(factor: MfaFactor): Promise<void> {
51
+ this.#store.set(factor.id, factor);
52
+ }
53
+ async findById(id: string): Promise<MfaFactor | null> {
54
+ return this.#store.get(id) ?? null;
55
+ }
56
+ async findByUser(userId: string): Promise<MfaFactor[]> {
57
+ return [...this.#store.values()].filter((f) => f.userId === userId);
58
+ }
59
+ async delete(id: string): Promise<void> {
60
+ this.#store.delete(id);
61
+ }
62
+ }
63
+
64
+ export interface MfaRateLimitConfig {
65
+ /** Failed verifications allowed per user before lockout. Default `5`. */
66
+ maxAttempts?: number;
67
+ /** Lockout / counter window in seconds. Default `900` (15 min). */
68
+ windowSeconds?: number;
69
+ }
70
+
71
+ export interface MfaManagerConfig {
72
+ /** App/brand name shown in the authenticator (the TOTP issuer). */
73
+ issuer: string;
74
+ totp?: TotpProvider;
75
+ backupCodes?: BackupCodesProvider;
76
+ store?: MfaFactorStore;
77
+ /** Per-user brute-force protection on `verify()`. Defaults to 5 / 15 min. */
78
+ rateLimit?: MfaRateLimitConfig;
79
+ }
80
+
81
+ interface AttemptEntry {
82
+ count: number;
83
+ resetAt: number;
84
+ }
85
+
86
+ export class MfaManager {
87
+ readonly #issuer: string;
88
+ readonly #totp?: TotpProvider;
89
+ readonly #backupCodes?: BackupCodesProvider;
90
+ readonly #store: MfaFactorStore;
91
+ readonly #maxAttempts: number;
92
+ readonly #windowMs: number;
93
+ readonly #attempts = new Map<string, AttemptEntry>();
94
+
95
+ constructor(config: MfaManagerConfig) {
96
+ if (!config?.issuer) {
97
+ throw new WardenError("INVALID_CONFIG", "MfaManager requires an issuer");
98
+ }
99
+ this.#issuer = config.issuer;
100
+ this.#totp = config.totp;
101
+ this.#backupCodes = config.backupCodes;
102
+ this.#store = config.store ?? new MemoryMfaFactorStore();
103
+ this.#maxAttempts = config.rateLimit?.maxAttempts ?? 5;
104
+ this.#windowMs = (config.rateLimit?.windowSeconds ?? 900) * 1000;
105
+ }
106
+
107
+ // ── TOTP enrollment ──────────────────────────────────────────────
108
+
109
+ /**
110
+ * Begin TOTP enrollment: persist a pending factor and return the secret +
111
+ * `otpauth://` URI to show as a QR code. The factor is unusable until
112
+ * `confirmTotp()` succeeds.
113
+ */
114
+ async enrollTotp(
115
+ user: { id: string; name: string },
116
+ label?: string,
117
+ ): Promise<{ factorId: string; secret: string; uri: string }> {
118
+ const totp = this.#requireTotp();
119
+ const { secret, uri } = totp.enroll(user.name, this.#issuer);
120
+ const factor: MfaFactor = {
121
+ id: newId(),
122
+ userId: user.id,
123
+ kind: "totp",
124
+ label,
125
+ secret,
126
+ createdAt: Date.now(),
127
+ };
128
+ await this.#store.save(factor);
129
+ return { factorId: factor.id, secret, uri };
130
+ }
131
+
132
+ /** Confirm a pending TOTP factor by verifying a first code. */
133
+ async confirmTotp(factorId: string, code: string): Promise<boolean> {
134
+ const totp = this.#requireTotp();
135
+ const factor = await this.#store.findById(factorId);
136
+ if (!factor || factor.kind !== "totp" || !factor.secret) {
137
+ return false;
138
+ }
139
+ if (!totp.verify(factor.secret, code)) {
140
+ return false;
141
+ }
142
+ await this.#store.save({ ...factor, confirmedAt: Date.now() });
143
+ return true;
144
+ }
145
+
146
+ /** Verify a TOTP code against the user's confirmed authenticator factors. */
147
+ async verifyTotp(userId: string, code: string): Promise<boolean> {
148
+ const totp = this.#requireTotp();
149
+ const factors = await this.#store.findByUser(userId);
150
+ return factors.some(
151
+ (f) =>
152
+ f.kind === "totp" &&
153
+ f.confirmedAt !== undefined &&
154
+ f.secret !== undefined &&
155
+ totp.verify(f.secret, code),
156
+ );
157
+ }
158
+
159
+ // ── Backup codes ─────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Generate (or regenerate) the user's backup codes. Returns the plaintext
163
+ * codes to display ONCE; only hashes are stored. Any prior backup-code
164
+ * factor is replaced.
165
+ */
166
+ async createBackupCodes(userId: string, label?: string): Promise<string[]> {
167
+ const provider = this.#requireBackupCodes();
168
+ const existing = await this.#store.findByUser(userId);
169
+ for (const f of existing) {
170
+ if (f.kind === "backup_codes") {
171
+ await this.#store.delete(f.id);
172
+ }
173
+ }
174
+ const { codes, hashes } = provider.generate();
175
+ await this.#store.save({
176
+ id: newId(),
177
+ userId,
178
+ kind: "backup_codes",
179
+ label,
180
+ backupHashes: hashes,
181
+ // Backup codes are usable as soon as they are shown.
182
+ confirmedAt: Date.now(),
183
+ createdAt: Date.now(),
184
+ });
185
+ return codes;
186
+ }
187
+
188
+ /** Verify and consume one backup code for the user. */
189
+ async verifyBackupCode(userId: string, code: string): Promise<boolean> {
190
+ const provider = this.#requireBackupCodes();
191
+ const factors = await this.#store.findByUser(userId);
192
+ for (const factor of factors) {
193
+ if (factor.kind !== "backup_codes" || !factor.backupHashes) {
194
+ continue;
195
+ }
196
+ const result = provider.verify(factor.backupHashes, code);
197
+ if (result.ok) {
198
+ await this.#store.save({ ...factor, backupHashes: result.remaining });
199
+ return true;
200
+ }
201
+ }
202
+ return false;
203
+ }
204
+
205
+ // ── Unified verify + status ──────────────────────────────────────
206
+
207
+ /**
208
+ * Verify a code entered in a single "2FA code" field: tries the user's TOTP
209
+ * factors first, then falls back to consuming a backup code.
210
+ */
211
+ async verify(userId: string, code: string): Promise<boolean> {
212
+ if (this.#isLocked(userId)) {
213
+ return false;
214
+ }
215
+ let ok = false;
216
+ if (this.#totp && (await this.verifyTotp(userId, code))) {
217
+ ok = true;
218
+ } else if (
219
+ this.#backupCodes &&
220
+ (await this.verifyBackupCode(userId, code))
221
+ ) {
222
+ ok = true;
223
+ }
224
+ if (ok) {
225
+ this.#attempts.delete(userId);
226
+ } else {
227
+ this.#recordFailure(userId);
228
+ }
229
+ return ok;
230
+ }
231
+
232
+ /**
233
+ * Whether the user is currently locked out of `verify()` after too many
234
+ * failed attempts. Use it to surface a "try again later" message.
235
+ */
236
+ isLocked(userId: string): boolean {
237
+ return this.#isLocked(userId);
238
+ }
239
+
240
+ /** List the user's factors without exposing any secret material. */
241
+ async listFactors(userId: string): Promise<MfaFactorSummary[]> {
242
+ const factors = await this.#store.findByUser(userId);
243
+ return factors.map((f) => ({
244
+ id: f.id,
245
+ kind: f.kind,
246
+ label: f.label,
247
+ confirmed: f.confirmedAt !== undefined,
248
+ }));
249
+ }
250
+
251
+ /** Whether the user has at least one confirmed factor. */
252
+ async isEnabled(userId: string): Promise<boolean> {
253
+ const factors = await this.#store.findByUser(userId);
254
+ return factors.some((f) => f.confirmedAt !== undefined);
255
+ }
256
+
257
+ /** Remove a factor (e.g. the user disables their authenticator). */
258
+ async disableFactor(factorId: string): Promise<void> {
259
+ await this.#store.delete(factorId);
260
+ }
261
+
262
+ #isLocked(userId: string): boolean {
263
+ const entry = this.#attempts.get(userId);
264
+ if (!entry) {
265
+ return false;
266
+ }
267
+ if (entry.resetAt < Date.now()) {
268
+ this.#attempts.delete(userId);
269
+ return false;
270
+ }
271
+ return entry.count >= this.#maxAttempts;
272
+ }
273
+
274
+ #recordFailure(userId: string): void {
275
+ const now = Date.now();
276
+ let entry = this.#attempts.get(userId);
277
+ if (!entry || entry.resetAt < now) {
278
+ entry = { count: 0, resetAt: now + this.#windowMs };
279
+ this.#attempts.set(userId, entry);
280
+ }
281
+ entry.count++;
282
+ }
283
+
284
+ #requireTotp(): TotpProvider {
285
+ if (!this.#totp) {
286
+ throw new WardenError(
287
+ "INVALID_CONFIG",
288
+ "MfaManager has no TotpProvider configured",
289
+ );
290
+ }
291
+ return this.#totp;
292
+ }
293
+
294
+ #requireBackupCodes(): BackupCodesProvider {
295
+ if (!this.#backupCodes) {
296
+ throw new WardenError(
297
+ "INVALID_CONFIG",
298
+ "MfaManager has no BackupCodesProvider configured",
299
+ );
300
+ }
301
+ return this.#backupCodes;
302
+ }
303
+ }
304
+
305
+ function newId(): string {
306
+ return randomBytes(16).toString("hex");
307
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * OtpProvider — delivered one-time passcodes (email / SMS). Unlike TOTP this is
3
+ * a challenge/response flow: `start()` mints a code, persists it (hashed, with
4
+ * an expiry and an attempt budget) and hands it to a delivery channel; the user
5
+ * later submits it to `verify()`.
6
+ *
7
+ * Delivery is pluggable — the app supplies an email or SMS channel. The store
8
+ * is pluggable too, with an in-memory default mirroring the rest of warden.
9
+ */
10
+
11
+ import {
12
+ createHash,
13
+ randomBytes,
14
+ randomInt,
15
+ timingSafeEqual,
16
+ } from "node:crypto";
17
+ import { WardenError } from "../errors.js";
18
+
19
+ /** Sends the code to the user. Implemented by the consuming app (email/SMS). */
20
+ export interface OtpDeliveryChannel {
21
+ send(recipient: string, code: string): Promise<void>;
22
+ }
23
+
24
+ /** A persisted, pending OTP challenge. */
25
+ export interface OtpChallenge {
26
+ id: string;
27
+ recipient: string;
28
+ /** Salted hash of the code — never the plaintext. */
29
+ hash: string;
30
+ expiresAt: number;
31
+ attempts: number;
32
+ }
33
+
34
+ /** Storage for pending challenges. */
35
+ export interface OtpChallengeStore {
36
+ save(challenge: OtpChallenge): Promise<void>;
37
+ find(id: string): Promise<OtpChallenge | null>;
38
+ delete(id: string): Promise<void>;
39
+ }
40
+
41
+ export class MemoryOtpChallengeStore implements OtpChallengeStore {
42
+ #store = new Map<string, OtpChallenge>();
43
+ async save(c: OtpChallenge): Promise<void> {
44
+ this.#store.set(c.id, c);
45
+ }
46
+ async find(id: string): Promise<OtpChallenge | null> {
47
+ return this.#store.get(id) ?? null;
48
+ }
49
+ async delete(id: string): Promise<void> {
50
+ this.#store.delete(id);
51
+ }
52
+ }
53
+
54
+ export interface OtpConfig {
55
+ channel: OtpDeliveryChannel;
56
+ /** Where pending challenges live. Default in-memory. */
57
+ store?: OtpChallengeStore;
58
+ /** Number of digits in the code. Default `6`. */
59
+ digits?: number;
60
+ /** How long a code stays valid, in seconds. Default `300` (5 min). */
61
+ ttlSeconds?: number;
62
+ /** Max verification attempts before the challenge is burned. Default `5`. */
63
+ maxAttempts?: number;
64
+ }
65
+
66
+ export interface OtpStartResult {
67
+ challengeId: string;
68
+ expiresAt: number;
69
+ }
70
+
71
+ export type OtpFailureReason =
72
+ | "not_found"
73
+ | "expired"
74
+ | "too_many_attempts"
75
+ | "mismatch";
76
+
77
+ export interface OtpVerification {
78
+ ok: boolean;
79
+ reason?: OtpFailureReason;
80
+ }
81
+
82
+ export class OtpProvider {
83
+ readonly kind = "otp" as const;
84
+ readonly #channel: OtpDeliveryChannel;
85
+ readonly #store: OtpChallengeStore;
86
+ readonly #digits: number;
87
+ readonly #ttlMs: number;
88
+ readonly #maxAttempts: number;
89
+
90
+ constructor(config: OtpConfig) {
91
+ if (!config?.channel) {
92
+ throw new WardenError(
93
+ "INVALID_CONFIG",
94
+ "OtpProvider requires a delivery channel (email/SMS)",
95
+ );
96
+ }
97
+ this.#channel = config.channel;
98
+ this.#store = config.store ?? new MemoryOtpChallengeStore();
99
+ this.#digits = config.digits ?? 6;
100
+ this.#ttlMs = (config.ttlSeconds ?? 300) * 1000;
101
+ this.#maxAttempts = config.maxAttempts ?? 5;
102
+ }
103
+
104
+ /** Mint a code, persist the challenge, and deliver it to `recipient`. */
105
+ async start(
106
+ recipient: string,
107
+ nowMs: number = Date.now(),
108
+ ): Promise<OtpStartResult> {
109
+ const code = this.#randomCode();
110
+ const id = randomBytes(16).toString("hex");
111
+ const expiresAt = nowMs + this.#ttlMs;
112
+ await this.#store.save({
113
+ id,
114
+ recipient,
115
+ hash: saltedHash(code),
116
+ expiresAt,
117
+ attempts: 0,
118
+ });
119
+ await this.#channel.send(recipient, code);
120
+ return { challengeId: id, expiresAt };
121
+ }
122
+
123
+ /**
124
+ * Verify a submitted code. Wrong codes consume an attempt; the challenge is
125
+ * deleted on success, on expiry, or once the attempt budget is exhausted.
126
+ */
127
+ async verify(
128
+ challengeId: string,
129
+ code: string,
130
+ nowMs: number = Date.now(),
131
+ ): Promise<OtpVerification> {
132
+ const challenge = await this.#store.find(challengeId);
133
+ if (!challenge) {
134
+ return { ok: false, reason: "not_found" };
135
+ }
136
+ if (nowMs > challenge.expiresAt) {
137
+ await this.#store.delete(challengeId);
138
+ return { ok: false, reason: "expired" };
139
+ }
140
+ if (matchesStored(challenge.hash, code.replace(/\s/g, ""))) {
141
+ await this.#store.delete(challengeId);
142
+ return { ok: true };
143
+ }
144
+ const attempts = challenge.attempts + 1;
145
+ if (attempts >= this.#maxAttempts) {
146
+ await this.#store.delete(challengeId);
147
+ return { ok: false, reason: "too_many_attempts" };
148
+ }
149
+ await this.#store.save({ ...challenge, attempts });
150
+ return { ok: false, reason: "mismatch" };
151
+ }
152
+
153
+ #randomCode(): string {
154
+ const max = 10 ** this.#digits;
155
+ return randomInt(0, max).toString().padStart(this.#digits, "0");
156
+ }
157
+ }
158
+
159
+ function saltedHash(code: string): string {
160
+ const salt = randomBytes(8);
161
+ const digest = createHash("sha256").update(salt).update(code).digest();
162
+ return `${salt.toString("hex")}$${digest.toString("hex")}`;
163
+ }
164
+
165
+ function matchesStored(stored: string, candidate: string): boolean {
166
+ const sep = stored.indexOf("$");
167
+ if (sep === -1) {
168
+ return false;
169
+ }
170
+ const salt = Buffer.from(stored.slice(0, sep), "hex");
171
+ const expected = Buffer.from(stored.slice(sep + 1), "hex");
172
+ const actual = createHash("sha256").update(salt).update(candidate).digest();
173
+ if (actual.length !== expected.length) {
174
+ return false;
175
+ }
176
+ return timingSafeEqual(actual, expected);
177
+ }