@aooth/user 0.1.1

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/dist/index.cjs ADDED
@@ -0,0 +1,824 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_user_store = require("./user-store-CdWrTeqR.cjs");
3
+ let node_crypto = require("node:crypto");
4
+ let _prostojs_ftring = require("@prostojs/ftring");
5
+ //#region src/mfa/backup-codes.ts
6
+ /**
7
+ * Custom alphabet for backup codes — uppercase letters and digits with the
8
+ * easily-confused characters (I, O, L, 0, 1) removed (31 chars).
9
+ */
10
+ const BACKUP_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
11
+ const RAW_LENGTH = 10;
12
+ const GROUP_SIZE = 4;
13
+ /**
14
+ * Generate `count` cryptographically-random backup codes (default 10).
15
+ *
16
+ * Format: 10 characters from the 31-char safe alphabet (uppercase letters +
17
+ * digits, omitting I/O/L/0/1), grouped as `XXXX-XXXX-XX`.
18
+ *
19
+ * Returns plaintext codes for the caller to deliver to the user — these
20
+ * should be hashed via {@link hashMfaCode} before persistence and never
21
+ * shown to the user again.
22
+ */
23
+ function generateBackupCodePlaintext(count = 10) {
24
+ const codes = [];
25
+ for (let i = 0; i < count; i++) codes.push(formatCode(require_user_store.generateSecureRandom(RAW_LENGTH, BACKUP_CODE_ALPHABET)));
26
+ return codes;
27
+ }
28
+ function formatCode(raw) {
29
+ const parts = [];
30
+ for (let i = 0; i < raw.length; i += GROUP_SIZE) parts.push(raw.slice(i, i + GROUP_SIZE));
31
+ return parts.join("-");
32
+ }
33
+ //#endregion
34
+ //#region src/mfa/codes.ts
35
+ /**
36
+ * SHA-256 hash of an MFA code (e.g. one-time email/SMS code or backup code).
37
+ *
38
+ * Hex-encoded for stable, comparable output regardless of input case/format.
39
+ * Use {@link verifyMfaCode} to compare a submitted plaintext code against the
40
+ * stored hash in constant time.
41
+ */
42
+ function hashMfaCode(code) {
43
+ return (0, node_crypto.createHash)("sha256").update(code).digest("hex");
44
+ }
45
+ /**
46
+ * Constant-time comparison of a submitted plaintext code against an
47
+ * expected SHA-256 hex hash (as produced by {@link hashMfaCode}).
48
+ *
49
+ * Returns false for malformed/empty expected hashes (timingSafeEqual
50
+ * requires equal-length, non-empty buffers).
51
+ */
52
+ function verifyMfaCode(submitted, expectedHash) {
53
+ if (!expectedHash) return false;
54
+ const a = Buffer.from(hashMfaCode(submitted), "hex");
55
+ const b = Buffer.from(expectedHash, "hex");
56
+ if (a.length !== b.length) return false;
57
+ return (0, node_crypto.timingSafeEqual)(a, b);
58
+ }
59
+ //#endregion
60
+ //#region src/base-x/base32.ts
61
+ /**
62
+ * Partially copied from "thirty-two" library, all credits to Chris Umbel.
63
+ *
64
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
65
+ * of this software and associated documentation files (the "Software"), to deal
66
+ * in the Software without restriction, including without limitation the rights
67
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
68
+ * copies of the Software, and to permit persons to whom the Software is
69
+ * furnished to do so, subject to the following conditions:
70
+ *
71
+ * The above copyright notice and this permission notice shall be included in
72
+ * all copies or substantial portions of the Software.
73
+ *
74
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
75
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
76
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
77
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
78
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
79
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
80
+ * THE SOFTWARE.
81
+ */
82
+ const charTable = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
83
+ function quintetCount(buff) {
84
+ const quintets = Math.floor(buff.length / 5);
85
+ return buff.length % 5 === 0 ? quintets : quintets + 1;
86
+ }
87
+ const encode = function(plain) {
88
+ if (!Buffer.isBuffer(plain) && typeof plain !== "string") throw new TypeError("base32.encode only takes Buffer or string as parameter");
89
+ if (!Buffer.isBuffer(plain)) plain = Buffer.from(plain);
90
+ let i = 0;
91
+ let j = 0;
92
+ let shiftIndex = 0;
93
+ let digit = 0;
94
+ const encoded = Buffer.alloc(quintetCount(plain) * 8);
95
+ while (i < plain.length) {
96
+ const current = plain[i];
97
+ if (shiftIndex > 3) {
98
+ digit = current & 255 >> shiftIndex;
99
+ shiftIndex = (shiftIndex + 5) % 8;
100
+ digit = digit << shiftIndex | (i + 1 < plain.length ? plain[i + 1] : 0) >> 8 - shiftIndex;
101
+ i++;
102
+ } else {
103
+ digit = current >> 8 - (shiftIndex + 5) & 31;
104
+ shiftIndex = (shiftIndex + 5) % 8;
105
+ if (shiftIndex === 0) i++;
106
+ }
107
+ encoded[j] = charTable.charCodeAt(digit);
108
+ j++;
109
+ }
110
+ for (i = j; i < encoded.length; i++) encoded[i] = 61;
111
+ return encoded;
112
+ };
113
+ function decode(input) {
114
+ const cleaned = input.toUpperCase().replace(/=+$/, "");
115
+ let bits = 0;
116
+ let value = 0;
117
+ let index = 0;
118
+ const output = Buffer.alloc(Math.floor(cleaned.length * 5 / 8));
119
+ for (const ch of cleaned) {
120
+ const val = charTable.indexOf(ch);
121
+ if (val === -1) continue;
122
+ value = value << 5 | val;
123
+ bits += 5;
124
+ if (bits >= 8) {
125
+ output[index++] = value >>> bits - 8 & 255;
126
+ bits -= 8;
127
+ }
128
+ }
129
+ return output.subarray(0, index);
130
+ }
131
+ //#endregion
132
+ //#region src/mfa/totp.ts
133
+ function generateTotpSecret(bytes = 20) {
134
+ return encode((0, node_crypto.randomBytes)(bytes)).toString().replace(/=+$/, "").toUpperCase();
135
+ }
136
+ function generateTotpUri(secret, issuer, account, config) {
137
+ const period = config?.period ?? 30;
138
+ const digits = config?.digits ?? 6;
139
+ const encodedIssuer = encodeURIComponent(issuer);
140
+ return `otpauth://totp/${encodedIssuer}:${encodeURIComponent(account)}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=${digits}&period=${period}`;
141
+ }
142
+ function generateTotpCode(secret, config) {
143
+ const period = config?.period ?? 30;
144
+ const digits = config?.digits ?? 6;
145
+ const now = (config?.clock ?? Date.now)();
146
+ const counter = Math.floor(now / 1e3 / period);
147
+ return hotpCode(decode(secret), counter, digits);
148
+ }
149
+ function verifyTotpCode(secret, code, config) {
150
+ const period = config?.period ?? 30;
151
+ const digits = config?.digits ?? 6;
152
+ const window = config?.window ?? 1;
153
+ const now = (config?.clock ?? Date.now)();
154
+ const counter = Math.floor(now / 1e3 / period);
155
+ const key = decode(secret);
156
+ if (typeof code !== "string" || code.length !== digits) return false;
157
+ const submitted = Buffer.from(code, "utf8");
158
+ let matched = false;
159
+ for (let i = -window; i <= window; i++) {
160
+ const expected = Buffer.from(hotpCode(key, counter + i, digits), "utf8");
161
+ if (expected.length === submitted.length && (0, node_crypto.timingSafeEqual)(expected, submitted)) matched = true;
162
+ }
163
+ return matched;
164
+ }
165
+ function generateMfaCode(length = 6) {
166
+ return require_user_store.generateSecureRandom(length, "0123456789");
167
+ }
168
+ function hotpCode(key, counter, digits) {
169
+ const counterBuf = Buffer.alloc(8);
170
+ counterBuf.writeUInt32BE(Math.floor(counter / 4294967296), 0);
171
+ counterBuf.writeUInt32BE(counter >>> 0, 4);
172
+ const hmac = (0, node_crypto.createHmac)("sha1", key);
173
+ hmac.update(counterBuf);
174
+ const hmacResult = hmac.digest();
175
+ const offset = hmacResult[hmacResult.length - 1] & 15;
176
+ return (((hmacResult[offset] & 127) << 24 | (hmacResult[offset + 1] & 255) << 16 | (hmacResult[offset + 2] & 255) << 8 | hmacResult[offset + 3] & 255) % 10 ** digits).toString().padStart(digits, "0");
177
+ }
178
+ //#endregion
179
+ //#region src/password/hasher.ts
180
+ const DEFAULTS = {
181
+ N: 16384,
182
+ r: 8,
183
+ p: 1,
184
+ keyLength: 64,
185
+ saltLength: 32
186
+ };
187
+ const PREFIX = "$scrypt$";
188
+ function scryptAsync(password, salt, keyLength, options) {
189
+ return new Promise((resolve, reject) => {
190
+ (0, node_crypto.scrypt)(password, salt, keyLength, options, (err, derived) => {
191
+ if (err) reject(err);
192
+ else resolve(derived);
193
+ });
194
+ });
195
+ }
196
+ function parseHash(encoded) {
197
+ if (!encoded.startsWith(PREFIX)) return null;
198
+ const parts = encoded.slice(8).split("$");
199
+ if (parts.length !== 3) return null;
200
+ const params = {};
201
+ for (const kv of parts[0].split(",")) {
202
+ const [k, v] = kv.split("=");
203
+ params[k] = Number(v);
204
+ }
205
+ if (!params.N || !params.r || !params.p || !params.l) return null;
206
+ return {
207
+ N: params.N,
208
+ r: params.r,
209
+ p: params.p,
210
+ keyLength: params.l,
211
+ salt: Buffer.from(parts[1], "base64url"),
212
+ hash: Buffer.from(parts[2], "base64url")
213
+ };
214
+ }
215
+ var PasswordHasher = class {
216
+ pepper;
217
+ N;
218
+ r;
219
+ p;
220
+ keyLength;
221
+ constructor(config) {
222
+ this.pepper = config?.pepper ?? "";
223
+ this.N = config?.scryptN ?? DEFAULTS.N;
224
+ this.r = config?.scryptR ?? DEFAULTS.r;
225
+ this.p = config?.scryptP ?? DEFAULTS.p;
226
+ this.keyLength = config?.keyLength ?? DEFAULTS.keyLength;
227
+ }
228
+ async hash(password) {
229
+ const salt = (0, node_crypto.randomBytes)(DEFAULTS.saltLength);
230
+ const derived = await scryptAsync(this.pepper + password, salt, this.keyLength, {
231
+ N: this.N,
232
+ r: this.r,
233
+ p: this.p
234
+ });
235
+ return `${PREFIX}${`N=${this.N},r=${this.r},p=${this.p},l=${this.keyLength}`}$${salt.toString("base64url")}$${derived.toString("base64url")}`;
236
+ }
237
+ async verify(password, encoded) {
238
+ const parsed = parseHash(encoded);
239
+ if (!parsed) return false;
240
+ const derived = await scryptAsync(this.pepper + password, parsed.salt, parsed.keyLength, {
241
+ N: parsed.N,
242
+ r: parsed.r,
243
+ p: parsed.p
244
+ });
245
+ if (derived.length !== parsed.hash.length) return false;
246
+ return (0, node_crypto.timingSafeEqual)(derived, parsed.hash);
247
+ }
248
+ generatePassword(length = 16) {
249
+ const minLen = Math.max(length, 8);
250
+ const lower = "abcdefghijklmnopqrstuvwxyz";
251
+ const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
252
+ const digits = "0123456789";
253
+ const special = "!@#$%^&*()-_=+";
254
+ const all = lower + upper + digits + special;
255
+ const bytes = (0, node_crypto.randomBytes)(minLen);
256
+ const result = Array.from({ length: minLen });
257
+ result[0] = lower[bytes[0] % 26];
258
+ result[1] = upper[bytes[1] % 26];
259
+ result[2] = digits[bytes[2] % 10];
260
+ result[3] = special[bytes[3] % 14];
261
+ for (let i = 4; i < minLen; i++) result[i] = all[bytes[i] % all.length];
262
+ const shuffleBytes = (0, node_crypto.randomBytes)(minLen);
263
+ for (let i = minLen - 1; i > 0; i--) {
264
+ const j = shuffleBytes[i] % (i + 1);
265
+ [result[i], result[j]] = [result[j], result[i]];
266
+ }
267
+ return result.join("");
268
+ }
269
+ };
270
+ //#endregion
271
+ //#region src/password/policy.ts
272
+ const fnPool = new _prostojs_ftring.FtringsPool();
273
+ function normalizePolicies(policies) {
274
+ return (policies || []).map((p) => p instanceof PasswordPolicy ? p : new PasswordPolicy(p));
275
+ }
276
+ var PasswordPolicy = class {
277
+ rule;
278
+ description;
279
+ errorMessage;
280
+ constructor(config) {
281
+ this.rule = config.rule;
282
+ this.description = config.description || "";
283
+ this.errorMessage = config.errorMessage || "";
284
+ }
285
+ _evalFn;
286
+ evaluate(password, context) {
287
+ if (!this._evalFn) if (typeof this.rule === "function") this._evalFn = this.rule;
288
+ else if (typeof this.rule === "string" && this.rule) {
289
+ const fn = fnPool.getFn(this.rule);
290
+ this._evalFn = (v, ctx) => fn({
291
+ v,
292
+ context: ctx
293
+ });
294
+ } else this._evalFn = () => true;
295
+ return this._evalFn(password, context);
296
+ }
297
+ get transferable() {
298
+ return typeof this.rule === "string";
299
+ }
300
+ };
301
+ //#endregion
302
+ //#region src/user-service.ts
303
+ function resolveConfig(config) {
304
+ return {
305
+ password: {
306
+ pepper: config?.password?.pepper ?? "",
307
+ historyLength: config?.password?.historyLength ?? 0,
308
+ scryptN: config?.password?.scryptN ?? 16384,
309
+ scryptR: config?.password?.scryptR ?? 8,
310
+ scryptP: config?.password?.scryptP ?? 1,
311
+ keyLength: config?.password?.keyLength ?? 64,
312
+ policies: normalizePolicies(config?.password?.policies)
313
+ },
314
+ lockout: {
315
+ threshold: config?.lockout?.threshold ?? 0,
316
+ duration: config?.lockout?.duration ?? 0
317
+ },
318
+ clock: config?.clock ?? Date.now,
319
+ ...config?.deviceTrust && { deviceTrust: config.deviceTrust }
320
+ };
321
+ }
322
+ const DEVICE_TRUST_TOKEN_BYTES = 32;
323
+ const DEVICE_TRUST_SEPARATOR = ".";
324
+ function signDeviceTrust(secret, payload) {
325
+ return (0, node_crypto.createHmac)("sha256", secret).update(payload).digest("hex");
326
+ }
327
+ function deviceTrustSafeEqual(a, b) {
328
+ const ab = Buffer.from(a);
329
+ const bb = Buffer.from(b);
330
+ if (ab.length !== bb.length) return false;
331
+ return (0, node_crypto.timingSafeEqual)(ab, bb);
332
+ }
333
+ var UserService = class {
334
+ config;
335
+ hasher;
336
+ constructor(store, config) {
337
+ this.store = store;
338
+ this.config = resolveConfig(config);
339
+ this.hasher = new PasswordHasher(this.config.password);
340
+ }
341
+ /**
342
+ * @param extras Optional partial user fields merged AFTER the base
343
+ * `UserCredentials` shape, so callers can populate consumer-specific
344
+ * required fields (e.g. `tenantId`) without subclassing the store.
345
+ * Because the merge is shallow and extras win, overlapping top-level
346
+ * keys (`id`, `account`, `mfa`, ...) replace the defaults entirely —
347
+ * pass nested objects with all required sub-fields if you intend to
348
+ * override them.
349
+ */
350
+ async createUser(username, password, extras) {
351
+ const pw = password ?? this.hasher.generatePassword();
352
+ const userData = {
353
+ username,
354
+ password: {
355
+ hash: await this.hasher.hash(pw),
356
+ history: [],
357
+ lastChanged: this.config.clock(),
358
+ isInitial: !password
359
+ },
360
+ account: {
361
+ active: false,
362
+ locked: false,
363
+ lockReason: "",
364
+ lockEnds: 0,
365
+ failedLoginAttempts: 0,
366
+ lastLogin: 0
367
+ },
368
+ mfa: {
369
+ methods: [],
370
+ defaultMethod: "",
371
+ autoSend: false
372
+ },
373
+ ...extras
374
+ };
375
+ await this.store.create(userData);
376
+ return userData;
377
+ }
378
+ async getUser(username) {
379
+ const user = await this.store.findByUsername(username);
380
+ if (!user) throw new require_user_store.UserAuthError("NOT_FOUND");
381
+ return user;
382
+ }
383
+ async login(username, password) {
384
+ const user = await this.store.findByUsername(username);
385
+ if (!user) throw new require_user_store.UserAuthError("NOT_FOUND");
386
+ if (!user.account.active) throw new require_user_store.UserAuthError("INACTIVE");
387
+ await this.ensureNotLockedOrThrow(username, user.account);
388
+ if (await this.hasher.verify(password, user.password.hash)) {
389
+ const now = this.config.clock();
390
+ await this.store.update(username, { set: { account: {
391
+ lastLogin: now,
392
+ failedLoginAttempts: 0
393
+ } } });
394
+ user.account.lastLogin = now;
395
+ user.account.failedLoginAttempts = 0;
396
+ return {
397
+ user,
398
+ mfaRequired: this.hasConfirmedMfaMethods(user.mfa)
399
+ };
400
+ }
401
+ return this.incrementAndMaybeLock(username, user.account, "INVALID_CREDENTIALS");
402
+ }
403
+ async verifyPassword(username, password) {
404
+ const user = await this.store.findByUsername(username);
405
+ if (!user) throw new require_user_store.UserAuthError("NOT_FOUND");
406
+ return this.hasher.verify(password, user.password.hash);
407
+ }
408
+ async changePassword(username, currentPassword, newPassword, repeatPassword) {
409
+ if (repeatPassword !== void 0 && newPassword !== repeatPassword) throw new require_user_store.UserAuthError("PASSWORDS_MISMATCH");
410
+ const user = await this.getUser(username);
411
+ if (!await this.hasher.verify(currentPassword, user.password.hash)) throw new require_user_store.UserAuthError("INVALID_CREDENTIALS");
412
+ await this.applyPasswordChange(username, user, newPassword);
413
+ }
414
+ async setPassword(username, newPassword) {
415
+ const user = await this.getUser(username);
416
+ await this.applyPasswordChange(username, user, newPassword);
417
+ }
418
+ /**
419
+ * Hard-delete the user row. Returns nothing on success. Throws
420
+ * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
421
+ * invite workflow's `auth.cancelInvite` to revoke a pending invitation.
422
+ */
423
+ async deleteUser(username) {
424
+ if (!await this.store.delete(username)) throw new require_user_store.UserAuthError("NOT_FOUND");
425
+ }
426
+ /**
427
+ * Deep-merge `patch` into the user record (top-level fields are shallow-
428
+ * merged; `account` / `mfa` / `password` are merged per their
429
+ * `@db.patch.strategy 'merge'` declaration). Returns the patched record.
430
+ * Used by the invite workflow's `applyProfile` default fallback.
431
+ */
432
+ async update(username, patch) {
433
+ if (!await this.store.update(username, { set: patch })) throw new require_user_store.UserAuthError("NOT_FOUND");
434
+ return this.getUser(username);
435
+ }
436
+ async activateAccount(username) {
437
+ if (!await this.store.update(username, { set: { account: { active: true } } })) throw new require_user_store.UserAuthError("NOT_FOUND");
438
+ }
439
+ async deactivateAccount(username) {
440
+ if (!await this.store.update(username, { set: { account: { active: false } } })) throw new require_user_store.UserAuthError("NOT_FOUND");
441
+ }
442
+ async lockAccount(username, reason, duration) {
443
+ const lockEnds = duration ? this.config.clock() + duration : 0;
444
+ if (!await this.store.update(username, { set: { account: {
445
+ locked: true,
446
+ lockReason: reason,
447
+ lockEnds
448
+ } } })) throw new require_user_store.UserAuthError("NOT_FOUND");
449
+ }
450
+ async unlockAccount(username) {
451
+ if (!await this.store.update(username, { set: { account: {
452
+ locked: false,
453
+ lockReason: "",
454
+ lockEnds: 0,
455
+ failedLoginAttempts: 0
456
+ } } })) throw new require_user_store.UserAuthError("NOT_FOUND");
457
+ }
458
+ getLockStatus(account) {
459
+ if (!account.locked) return {
460
+ locked: false,
461
+ expired: false,
462
+ reason: "",
463
+ lockEnds: 0
464
+ };
465
+ return {
466
+ locked: true,
467
+ expired: account.lockEnds > 0 && account.lockEnds < this.config.clock(),
468
+ reason: account.lockReason,
469
+ lockEnds: account.lockEnds
470
+ };
471
+ }
472
+ async checkPolicies(password, passwordData) {
473
+ const result = {
474
+ passed: true,
475
+ policies: [],
476
+ errors: []
477
+ };
478
+ for (const policy of this.config.password.policies) {
479
+ const passed = await policy.evaluate(password, {
480
+ passwordData,
481
+ passwordConfig: this.config.password
482
+ });
483
+ result.passed = result.passed && passed;
484
+ result.policies.push({
485
+ description: policy.description,
486
+ passed
487
+ });
488
+ if (!passed) result.errors.push(policy.errorMessage);
489
+ }
490
+ return result;
491
+ }
492
+ getTransferablePolicies() {
493
+ return this.config.password.policies.filter((p) => p.transferable).map((p) => ({
494
+ rule: p.rule,
495
+ description: p.description,
496
+ errorMessage: p.errorMessage
497
+ }));
498
+ }
499
+ async addMfaMethod(username, method) {
500
+ const methods = [...(await this.getUser(username)).mfa.methods.filter((m) => m.name !== method.name), method];
501
+ await this.store.update(username, { set: { mfa: { methods } } });
502
+ }
503
+ async confirmMfaMethod(username, name) {
504
+ const user = await this.getUser(username);
505
+ let found = false;
506
+ const methods = user.mfa.methods.map((m) => {
507
+ if (m.name === name) {
508
+ found = true;
509
+ return {
510
+ ...m,
511
+ confirmed: true
512
+ };
513
+ }
514
+ return m;
515
+ });
516
+ if (!found) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
517
+ await this.store.update(username, { set: { mfa: { methods } } });
518
+ }
519
+ async removeMfaMethod(username, name) {
520
+ const user = await this.getUser(username);
521
+ const update = { mfa: { methods: user.mfa.methods.filter((m) => m.name !== name) } };
522
+ if (user.mfa.defaultMethod === name) update.mfa.defaultMethod = "";
523
+ await this.store.update(username, { set: update });
524
+ }
525
+ async setDefaultMfaMethod(username, name) {
526
+ const user = await this.getUser(username);
527
+ if (name && !user.mfa.methods.some((m) => m.name === name)) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
528
+ await this.store.update(username, { set: { mfa: {
529
+ defaultMethod: name,
530
+ autoSend: false
531
+ } } });
532
+ }
533
+ async setMfaAutoSend(username, value) {
534
+ if (!await this.store.update(username, { set: { mfa: { autoSend: value } } })) throw new require_user_store.UserAuthError("NOT_FOUND");
535
+ }
536
+ getAvailableMfaMethods(mfa) {
537
+ return mfa.methods.filter((m) => m.confirmed).map((m) => ({
538
+ name: m.name,
539
+ isDefault: mfa.defaultMethod === m.name,
540
+ masked: require_user_store.maskMfaValue(m)
541
+ }));
542
+ }
543
+ /**
544
+ * Generate `count` plaintext backup codes (default 10), persist their
545
+ * hashes (replacing any existing batch), and return the plaintext codes
546
+ * once for the caller to deliver to the user. Plaintext is never
547
+ * recoverable after this call returns.
548
+ *
549
+ * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
550
+ */
551
+ async generateBackupCodes(username, count = 10) {
552
+ const codes = generateBackupCodePlaintext(count);
553
+ const hashes = codes.map(hashMfaCode);
554
+ if (!await this.store.update(username, { set: { backupCodes: hashes } })) throw new require_user_store.UserAuthError("NOT_FOUND");
555
+ return codes;
556
+ }
557
+ /**
558
+ * Consume a backup code: returns `true` and removes the matching hash
559
+ * from storage if `code` matches a stored backup code; returns `false`
560
+ * if no match (without modifying storage).
561
+ *
562
+ * Read-then-write is not atomic at this layer: two concurrent consumes
563
+ * of the same code may both succeed, with the last write winning. The
564
+ * underlying store API does not expose an atomic match-and-remove, so
565
+ * this is acceptable at the intended scale (backup codes are a fallback
566
+ * path, not a hot one). Wrap in your store's transaction primitive if a
567
+ * stricter guarantee is required.
568
+ *
569
+ * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
570
+ */
571
+ async consumeBackupCode(username, code) {
572
+ const hashes = (await this.getUser(username)).backupCodes ?? [];
573
+ const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
574
+ if (idx < 0) return false;
575
+ const remaining = hashes.filter((_, i) => i !== idx);
576
+ await this.store.update(username, { set: { backupCodes: remaining } });
577
+ return true;
578
+ }
579
+ /**
580
+ * Verify a TOTP code against the user's confirmed `totp` MFA method.
581
+ * Failures bump the same `failedLoginAttempts` counter as `login` so an
582
+ * attacker who knows the password but not the TOTP gets `lockout.threshold`
583
+ * total tries across BOTH factors, not `2 * threshold`.
584
+ */
585
+ async verifyMfa(username, code, config) {
586
+ const user = await this.getUser(username);
587
+ if (!user.account.active) throw new require_user_store.UserAuthError("INACTIVE");
588
+ await this.ensureNotLockedOrThrow(username, user.account);
589
+ const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
590
+ if (!totp) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
591
+ if (verifyTotpCode(totp.value, code, config)) {
592
+ if (user.account.failedLoginAttempts > 0) await this.store.update(username, { set: { account: { failedLoginAttempts: 0 } } });
593
+ return;
594
+ }
595
+ await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
596
+ }
597
+ getPasswordHasher() {
598
+ return this.hasher;
599
+ }
600
+ getConfig() {
601
+ return this.config;
602
+ }
603
+ /**
604
+ * Mint a freshly-signed trust record (does NOT persist — pair with
605
+ * `addTrustedDevice`). Throws when `deviceTrust.secret` is unset.
606
+ */
607
+ issueTrustedDevice(userId, opts) {
608
+ const secret = this.requireDeviceTrustSecret();
609
+ const raw = (0, node_crypto.randomBytes)(DEVICE_TRUST_TOKEN_BYTES).toString("hex");
610
+ const sig = signDeviceTrust(secret, `${userId}|${raw}|${opts.ip ?? ""}`);
611
+ const now = this.config.clock();
612
+ return {
613
+ token: `${raw}${DEVICE_TRUST_SEPARATOR}${sig}`,
614
+ ...opts.ip !== void 0 && { ip: opts.ip },
615
+ issuedAt: now,
616
+ expiresAt: now + opts.ttlMs,
617
+ ...opts.name !== void 0 && { name: opts.name }
618
+ };
619
+ }
620
+ /**
621
+ * Append a trust record to the user's `trustedDevices` list. Read-modify-
622
+ * write — the array shape is preserved end-to-end so DB adapters with a
623
+ * merge strategy replace the whole array.
624
+ */
625
+ async addTrustedDevice(username, record) {
626
+ const next = [...(await this.getUser(username)).trustedDevices ?? [], record];
627
+ await this.store.update(username, { set: { trustedDevices: next } });
628
+ }
629
+ /**
630
+ * Returns true when the supplied token (a) signs against the user+ip with
631
+ * the configured secret, AND (b) matches a persisted record that is still
632
+ * within its expiry window and whose bound IP (if any) matches.
633
+ */
634
+ async verifyTrustedDevice(username, token, ip) {
635
+ const secret = this.requireDeviceTrustSecret();
636
+ const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
637
+ if (sepIdx <= 0) return false;
638
+ const raw = token.slice(0, sepIdx);
639
+ if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${username}|${raw}|${ip ?? ""}`))) return false;
640
+ const user = await this.store.findByUsername(username);
641
+ if (!user) return false;
642
+ const list = user.trustedDevices ?? [];
643
+ const now = this.config.clock();
644
+ const found = list.find((r) => r.token === token && r.expiresAt > now);
645
+ if (!found) return false;
646
+ if (found.ip !== void 0 && found.ip !== ip) return false;
647
+ return true;
648
+ }
649
+ /**
650
+ * Remove a specific trust record from the user. No-op when the record is
651
+ * absent — mirrors the legacy `DeviceTrustStore.revoke` semantics.
652
+ */
653
+ async revokeTrustedDevice(username, token) {
654
+ const user = await this.store.findByUsername(username);
655
+ if (!user) return;
656
+ const list = user.trustedDevices ?? [];
657
+ const next = list.filter((r) => r.token !== token);
658
+ if (next.length === list.length) return;
659
+ await this.store.update(username, { set: { trustedDevices: next } });
660
+ }
661
+ async listTrustedDevices(username) {
662
+ return (await this.getUser(username)).trustedDevices ?? [];
663
+ }
664
+ requireDeviceTrustSecret() {
665
+ const secret = this.config.deviceTrust?.secret;
666
+ if (!secret) throw new Error("UserService: deviceTrust.secret is required to use trusted-device APIs");
667
+ return secret;
668
+ }
669
+ async applyPasswordChange(username, user, newPassword) {
670
+ const policyResult = await this.checkPolicies(newPassword, user.password);
671
+ if (!policyResult.passed) throw new require_user_store.UserAuthError("POLICY_VIOLATION", policyResult.errors.join("; "), { policies: policyResult.policies });
672
+ const hashesToCheck = [user.password.hash, ...user.password.history].filter(Boolean);
673
+ if (hashesToCheck.length > 0) {
674
+ if ((await Promise.all(hashesToCheck.map((h) => this.hasher.verify(newPassword, h)))).some(Boolean)) throw new require_user_store.UserAuthError("PASSWORD_IN_HISTORY");
675
+ }
676
+ const newHash = await this.hasher.hash(newPassword);
677
+ const limit = this.config.password.historyLength;
678
+ const newHistory = limit > 0 ? [user.password.hash, ...user.password.history].filter(Boolean).slice(0, limit) : [];
679
+ await this.store.update(username, { set: { password: {
680
+ hash: newHash,
681
+ history: newHistory,
682
+ lastChanged: this.config.clock(),
683
+ isInitial: false
684
+ } } });
685
+ }
686
+ hasConfirmedMfaMethods(mfa) {
687
+ return mfa.methods.some((m) => m.confirmed);
688
+ }
689
+ /**
690
+ * If `account.locked`: auto-unlock when the lock has expired (mutating
691
+ * `account` in place), or throw `LOCKED` otherwise.
692
+ */
693
+ async ensureNotLockedOrThrow(username, account) {
694
+ const lockStatus = this.getLockStatus(account);
695
+ if (!lockStatus.locked) return;
696
+ if (lockStatus.expired) {
697
+ await this.store.update(username, { set: { account: {
698
+ locked: false,
699
+ lockReason: "",
700
+ lockEnds: 0
701
+ } } });
702
+ account.locked = false;
703
+ account.lockEnds = 0;
704
+ account.lockReason = "";
705
+ return;
706
+ }
707
+ throw new require_user_store.UserAuthError("LOCKED", void 0, {
708
+ reason: account.lockReason,
709
+ lockEnds: account.lockEnds
710
+ });
711
+ }
712
+ /**
713
+ * Bump `failedLoginAttempts`, locking the account when threshold is hit,
714
+ * and always throw `errorCode` (with `details.lockEnds` when the lockout
715
+ * just tripped). Used by both `login` and `verifyMfa` so the two factors
716
+ * share one counter.
717
+ */
718
+ async incrementAndMaybeLock(username, account, errorCode) {
719
+ const newAttempts = account.failedLoginAttempts + 1;
720
+ const { threshold, duration } = this.config.lockout;
721
+ if (threshold > 0 && newAttempts >= threshold) {
722
+ const lockEnds = duration ? this.config.clock() + duration : 0;
723
+ await this.store.update(username, {
724
+ inc: { "account.failedLoginAttempts": 1 },
725
+ set: { account: {
726
+ locked: true,
727
+ lockReason: "Too many login attempts",
728
+ lockEnds
729
+ } }
730
+ });
731
+ throw new require_user_store.UserAuthError(errorCode, void 0, { lockEnds });
732
+ }
733
+ await this.store.update(username, { inc: { "account.failedLoginAttempts": 1 } });
734
+ throw new require_user_store.UserAuthError(errorCode);
735
+ }
736
+ };
737
+ //#endregion
738
+ //#region src/store/memory.ts
739
+ var UserStoreMemory = class extends require_user_store.UserStore {
740
+ store = /* @__PURE__ */ new Map();
741
+ constructor(seed) {
742
+ super();
743
+ if (seed) for (const [key, value] of Object.entries(seed)) this.store.set(key, structuredClone(value));
744
+ }
745
+ async exists(username) {
746
+ return this.store.has(username);
747
+ }
748
+ async findByUsername(username) {
749
+ const user = this.store.get(username);
750
+ return user ? structuredClone(user) : null;
751
+ }
752
+ async create(data) {
753
+ if (this.store.has(data.username)) throw new require_user_store.UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
754
+ this.store.set(data.username, structuredClone(data));
755
+ }
756
+ async update(username, update) {
757
+ const user = this.store.get(username);
758
+ if (!user) return false;
759
+ if (update.set) require_user_store.deepMerge(user, update.set);
760
+ if (update.inc) for (const [path, amount] of Object.entries(update.inc)) require_user_store.incrementAtPath(user, path, amount);
761
+ return true;
762
+ }
763
+ async delete(username) {
764
+ return this.store.delete(username);
765
+ }
766
+ };
767
+ //#endregion
768
+ //#region src/password/policies.ts
769
+ const ppHasMinLength = (min = 8) => ({
770
+ rule: `v.length >= ${min}`,
771
+ description: `Minimum length ${min}`,
772
+ errorMessage: `Password must be at least ${min} characters long`
773
+ });
774
+ const ppHasUpperCase = (n = 1) => ({
775
+ rule: `(v.match(/[A-Z]/g) || []).length >= ${n}`,
776
+ description: `At least ${n} uppercase character${n === 1 ? "" : "s"}`,
777
+ errorMessage: `Password must include at least ${n} uppercase character${n === 1 ? "" : "s"}`
778
+ });
779
+ const ppHasLowerCase = (n = 1) => ({
780
+ rule: `(v.match(/[a-z]/g) || []).length >= ${n}`,
781
+ description: `At least ${n} lowercase character${n === 1 ? "" : "s"}`,
782
+ errorMessage: `Password must include at least ${n} lowercase character${n === 1 ? "" : "s"}`
783
+ });
784
+ const ppHasNumber = (n = 1) => ({
785
+ rule: `(v.match(/\\d/g) || []).length >= ${n}`,
786
+ description: `At least ${n} number${n === 1 ? "" : "s"}`,
787
+ errorMessage: `Password must include at least ${n} number${n === 1 ? "" : "s"}`
788
+ });
789
+ const ppHasSpecialChar = (n = 1) => ({
790
+ rule: `(v.match(/[^A-Za-z0-9]/g) || []).length >= ${n}`,
791
+ description: `At least ${n} special character${n === 1 ? "" : "s"}`,
792
+ errorMessage: `Password must include at least ${n} special character${n === 1 ? "" : "s"}`
793
+ });
794
+ const ppMaxRepeatedChars = (maxRepeated = 2) => ({
795
+ rule: `/(.)\\1{${maxRepeated},}/.test(v) === false`,
796
+ description: `No more than ${maxRepeated} consecutive repeated characters`,
797
+ errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
798
+ });
799
+ //#endregion
800
+ exports.PasswordHasher = PasswordHasher;
801
+ exports.PasswordPolicy = PasswordPolicy;
802
+ exports.UserAuthError = require_user_store.UserAuthError;
803
+ exports.UserService = UserService;
804
+ exports.UserStore = require_user_store.UserStore;
805
+ exports.UserStoreMemory = UserStoreMemory;
806
+ exports.generateBackupCodePlaintext = generateBackupCodePlaintext;
807
+ exports.generateMfaCode = generateMfaCode;
808
+ exports.generateTotpCode = generateTotpCode;
809
+ exports.generateTotpSecret = generateTotpSecret;
810
+ exports.generateTotpUri = generateTotpUri;
811
+ exports.hashMfaCode = hashMfaCode;
812
+ exports.maskEmail = require_user_store.maskEmail;
813
+ exports.maskMfaValue = require_user_store.maskMfaValue;
814
+ exports.maskPhone = require_user_store.maskPhone;
815
+ exports.normalizePolicies = normalizePolicies;
816
+ exports.ppHasLowerCase = ppHasLowerCase;
817
+ exports.ppHasMinLength = ppHasMinLength;
818
+ exports.ppHasNumber = ppHasNumber;
819
+ exports.ppHasSpecialChar = ppHasSpecialChar;
820
+ exports.ppHasUpperCase = ppHasUpperCase;
821
+ exports.ppMaxRepeatedChars = ppMaxRepeatedChars;
822
+ exports.setAtPath = require_user_store.setAtPath;
823
+ exports.verifyMfaCode = verifyMfaCode;
824
+ exports.verifyTotpCode = verifyTotpCode;