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