@aooth/user 0.1.7 → 0.1.9

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 CHANGED
@@ -1,60 +1,5 @@
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-BaBmH13V.mjs";
2
- import { createHash, createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
3
- //#region src/mfa/backup-codes.ts
4
- /**
5
- * Custom alphabet for backup codes — uppercase letters and digits with the
6
- * easily-confused characters (I, O, L, 0, 1) removed (31 chars).
7
- */
8
- const BACKUP_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
9
- const RAW_LENGTH = 10;
10
- const GROUP_SIZE = 4;
11
- /**
12
- * Generate `count` cryptographically-random backup codes (default 10).
13
- *
14
- * Format: 10 characters from the 31-char safe alphabet (uppercase letters +
15
- * digits, omitting I/O/L/0/1), grouped as `XXXX-XXXX-XX`.
16
- *
17
- * Returns plaintext codes for the caller to deliver to the user — these
18
- * should be hashed via {@link hashMfaCode} before persistence and never
19
- * shown to the user again.
20
- */
21
- function generateBackupCodePlaintext(count = 10) {
22
- const codes = [];
23
- for (let i = 0; i < count; i++) codes.push(formatCode(generateSecureRandom(RAW_LENGTH, BACKUP_CODE_ALPHABET)));
24
- return codes;
25
- }
26
- function formatCode(raw) {
27
- const parts = [];
28
- for (let i = 0; i < raw.length; i += GROUP_SIZE) parts.push(raw.slice(i, i + GROUP_SIZE));
29
- return parts.join("-");
30
- }
31
- //#endregion
32
- //#region src/mfa/codes.ts
33
- /**
34
- * SHA-256 hash of an MFA code (e.g. one-time email/SMS code or backup code).
35
- *
36
- * Hex-encoded for stable, comparable output regardless of input case/format.
37
- * Use {@link verifyMfaCode} to compare a submitted plaintext code against the
38
- * stored hash in constant time.
39
- */
40
- function hashMfaCode(code) {
41
- return createHash("sha256").update(code).digest("hex");
42
- }
43
- /**
44
- * Constant-time comparison of a submitted plaintext code against an
45
- * expected SHA-256 hex hash (as produced by {@link hashMfaCode}).
46
- *
47
- * Returns false for malformed/empty expected hashes (timingSafeEqual
48
- * requires equal-length, non-empty buffers).
49
- */
50
- function verifyMfaCode(submitted, expectedHash) {
51
- if (!expectedHash) return false;
52
- const a = Buffer.from(hashMfaCode(submitted), "hex");
53
- const b = Buffer.from(expectedHash, "hex");
54
- if (a.length !== b.length) return false;
55
- return timingSafeEqual(a, b);
56
- }
57
- //#endregion
1
+ import { a as generateSecureRandom, c as maskMfaValue, d as UserAuthError, i as deepMerge, l as maskPhone, n as pickDefinedProfile, o as incrementAtPath, r as UserStore, s as maskEmail, t as FederatedIdentityStore, u as setAtPath } from "./federated-identity-store-CHW1xtMp.mjs";
2
+ import { createHash, createHmac, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
58
3
  //#region src/base-x/base32.ts
59
4
  /**
60
5
  * Partially copied from "thirty-two" library, all credits to Chris Umbel.
@@ -255,14 +200,14 @@ var PasswordHasher = class {
255
200
  const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
256
201
  const digits = "0123456789";
257
202
  const special = "!@#$%^&*()-_=+";
258
- const all = lower + upper + digits + special;
203
+ const all = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
259
204
  const bytes = randomBytes(minLen);
260
205
  const result = Array.from({ length: minLen });
261
206
  result[0] = lower[bytes[0] % 26];
262
207
  result[1] = upper[bytes[1] % 26];
263
208
  result[2] = digits[bytes[2] % 10];
264
209
  result[3] = special[bytes[3] % 14];
265
- for (let i = 4; i < minLen; i++) result[i] = all[bytes[i] % all.length];
210
+ for (let i = 4; i < minLen; i++) result[i] = all[bytes[i] % 76];
266
211
  const shuffleBytes = randomBytes(minLen);
267
212
  for (let i = minLen - 1; i > 0; i--) {
268
213
  const j = shuffleBytes[i] % (i + 1);
@@ -359,7 +304,17 @@ function deviceTrustSafeEqual(a, b) {
359
304
  if (ab.length !== bb.length) return false;
360
305
  return timingSafeEqual(ab, bb);
361
306
  }
307
+ /**
308
+ * Orchestrates user credentials over a pluggable {@link UserStore}.
309
+ *
310
+ * Identity model: the stable surrogate **`id`** is the token subject. `getUser`
311
+ * and every mutation/admin method are keyed by `id` (the value carried in the
312
+ * session and returned by `useAuth().getUserId()`); only `login` (and other
313
+ * handle-driven entry points) take a `username`/`email` login handle, resolved
314
+ * via `UserStore.findByHandle`.
315
+ */
362
316
  var UserService = class {
317
+ store;
363
318
  config;
364
319
  hasher;
365
320
  constructor(store, config) {
@@ -371,25 +326,31 @@ var UserService = class {
371
326
  * Creates a user with `account.active: false`. The invite workflow relies
372
327
  * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
373
328
  * inactive until they accept). For setup scripts / seeders / tests that
374
- * don't go through invite, follow up with `activateAccount(username)` or
375
- * `login()` will throw `UserAuthError("INACTIVE")` — which the login
376
- * workflow deliberately re-maps to `"Invalid credentials"` to avoid account
329
+ * don't go through invite, follow up with `activateAccount(id)` or `login()`
330
+ * will throw `UserAuthError("INACTIVE")` — which the login workflow
331
+ * deliberately re-maps to `"Invalid credentials"` to avoid account
377
332
  * enumeration, so the failure is silent client-side.
378
333
  *
334
+ * A stable `id` is minted here (server-managed surrogate, also the token
335
+ * subject) and returned on the record, so callers can `auth.issue(user.id)`
336
+ * without a re-read. Pass `id` via `extras` to override it.
337
+ *
379
338
  * @param extras Optional partial user fields merged AFTER the base
380
339
  * `UserCredentials` shape, so callers can populate consumer-specific
381
340
  * required fields (e.g. `tenantId`) without subclassing the store.
382
341
  * Because the merge is shallow and extras win, overlapping top-level
383
- * keys (`id`, `account`, `mfa`, ...) replace the defaults entirely —
384
- * pass nested objects with all required sub-fields if you intend to
385
- * override them.
342
+ * keys (`id`, `email`, `account`, `mfa`, ...) replace the defaults
343
+ * entirely — pass nested objects with all required sub-fields if you
344
+ * intend to override them.
386
345
  */
387
346
  async createUser(username, password, extras) {
388
347
  const pw = password ?? this.hasher.generatePassword();
348
+ const hash = await this.hasher.hash(pw);
389
349
  const userData = {
350
+ id: randomUUID(),
390
351
  username,
391
352
  password: {
392
- hash: await this.hasher.hash(pw),
353
+ hash,
393
354
  history: [],
394
355
  lastChanged: this.config.clock(),
395
356
  isInitial: !password
@@ -412,19 +373,36 @@ var UserService = class {
412
373
  await this.store.create(userData);
413
374
  return userData;
414
375
  }
415
- async getUser(username) {
416
- const user = await this.store.findByUsername(username);
376
+ /** Read by the stable `id` (the token subject). */
377
+ async getUser(id) {
378
+ const user = await this.store.findById(id);
417
379
  if (!user) throw new UserAuthError("NOT_FOUND");
418
380
  return user;
419
381
  }
420
- async login(username, password) {
421
- const user = await this.store.findByUsername(username);
382
+ /**
383
+ * Deterministic handle resolver — `username` exact, then `email` exact.
384
+ * Returns `null` when nothing matches. Maps a login/recovery handle to a row;
385
+ * `login` uses the same resolution.
386
+ */
387
+ async findByHandle(handle) {
388
+ return this.store.findByHandle(handle);
389
+ }
390
+ /**
391
+ * Permissive lookup — `id`, then `username`, then `email` (ordered, first
392
+ * match). For internal / admin / recovery callers that may hold either an id
393
+ * or a handle. NOT for the login path (use {@link login}/{@link findByHandle}).
394
+ */
395
+ async findByIdentifier(value) {
396
+ return this.store.findByIdentifier(value);
397
+ }
398
+ async login(handle, password, lockoutOverride) {
399
+ const user = await this.store.findByHandle(handle);
422
400
  if (!user) throw new UserAuthError("NOT_FOUND");
423
401
  if (!user.account.active) throw new UserAuthError("INACTIVE");
424
- await this.ensureNotLockedOrThrow(username, user.account);
402
+ await this.ensureNotLockedOrThrow(user.id, user.account);
425
403
  if (await this.hasher.verify(password, user.password.hash)) {
426
404
  const now = this.config.clock();
427
- await this.store.update(username, { set: { account: {
405
+ await this.store.update(user.id, { set: { account: {
428
406
  lastLogin: now,
429
407
  failedLoginAttempts: 0
430
408
  } } });
@@ -435,30 +413,30 @@ var UserService = class {
435
413
  mfaRequired: this.hasConfirmedMfaMethods(user.mfa)
436
414
  };
437
415
  }
438
- return this.incrementAndMaybeLock(username, user.account, "INVALID_CREDENTIALS");
416
+ return this.incrementAndMaybeLock(user.id, user.account, "INVALID_CREDENTIALS", lockoutOverride);
439
417
  }
440
- async verifyPassword(username, password) {
441
- const user = await this.store.findByUsername(username);
418
+ async verifyPassword(id, password) {
419
+ const user = await this.store.findById(id);
442
420
  if (!user) throw new UserAuthError("NOT_FOUND");
443
421
  return this.hasher.verify(password, user.password.hash);
444
422
  }
445
- async changePassword(username, currentPassword, newPassword, repeatPassword) {
423
+ async changePassword(id, currentPassword, newPassword, repeatPassword) {
446
424
  if (repeatPassword !== void 0 && newPassword !== repeatPassword) throw new UserAuthError("PASSWORDS_MISMATCH");
447
- const user = await this.getUser(username);
425
+ const user = await this.getUser(id);
448
426
  if (!await this.hasher.verify(currentPassword, user.password.hash)) throw new UserAuthError("INVALID_CREDENTIALS");
449
- await this.applyPasswordChange(username, user, newPassword);
427
+ await this.applyPasswordChange(id, user, newPassword);
450
428
  }
451
- async setPassword(username, newPassword) {
452
- const user = await this.getUser(username);
453
- await this.applyPasswordChange(username, user, newPassword);
429
+ async setPassword(id, newPassword) {
430
+ const user = await this.getUser(id);
431
+ await this.applyPasswordChange(id, user, newPassword);
454
432
  }
455
433
  /**
456
- * Hard-delete the user row. Returns nothing on success. Throws
457
- * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
458
- * invite workflow's `auth/invite/cancel` to revoke a pending invitation.
434
+ * Hard-delete the user row by `id`. Returns nothing on success. Throws
435
+ * `UserAuthError("NOT_FOUND")` when no row matches. Used by the invite
436
+ * workflow's `auth/invite/cancel` to revoke a pending invitation.
459
437
  */
460
- async deleteUser(username) {
461
- if (!await this.store.delete(username)) throw new UserAuthError("NOT_FOUND");
438
+ async deleteUser(id) {
439
+ if (!await this.store.delete(id)) throw new UserAuthError("NOT_FOUND");
462
440
  }
463
441
  /**
464
442
  * Deep-merge `patch` into the user record (top-level fields are shallow-
@@ -466,26 +444,26 @@ var UserService = class {
466
444
  * `@db.patch.strategy 'merge'` declaration). Returns the patched record.
467
445
  * Used by the invite workflow's `applyProfile` default fallback.
468
446
  */
469
- async update(username, patch) {
470
- if (!await this.store.update(username, { set: patch })) throw new UserAuthError("NOT_FOUND");
471
- return this.getUser(username);
447
+ async update(id, patch) {
448
+ if (!await this.store.update(id, { set: patch })) throw new UserAuthError("NOT_FOUND");
449
+ return this.getUser(id);
472
450
  }
473
- async activateAccount(username) {
474
- if (!await this.store.update(username, { set: { account: { active: true } } })) throw new UserAuthError("NOT_FOUND");
451
+ async activateAccount(id) {
452
+ if (!await this.store.update(id, { set: { account: { active: true } } })) throw new UserAuthError("NOT_FOUND");
475
453
  }
476
- async deactivateAccount(username) {
477
- if (!await this.store.update(username, { set: { account: { active: false } } })) throw new UserAuthError("NOT_FOUND");
454
+ async deactivateAccount(id) {
455
+ if (!await this.store.update(id, { set: { account: { active: false } } })) throw new UserAuthError("NOT_FOUND");
478
456
  }
479
- async lockAccount(username, reason, duration) {
457
+ async lockAccount(id, reason, duration) {
480
458
  const lockEnds = duration ? this.config.clock() + duration : 0;
481
- if (!await this.store.update(username, { set: { account: {
459
+ if (!await this.store.update(id, { set: { account: {
482
460
  locked: true,
483
461
  lockReason: reason,
484
462
  lockEnds
485
463
  } } })) throw new UserAuthError("NOT_FOUND");
486
464
  }
487
- async unlockAccount(username) {
488
- if (!await this.store.update(username, { set: { account: {
465
+ async unlockAccount(id) {
466
+ if (!await this.store.update(id, { set: { account: {
489
467
  locked: false,
490
468
  lockReason: "",
491
469
  lockEnds: 0,
@@ -551,13 +529,13 @@ var UserService = class {
551
529
  errorMessage: p.errorMessage
552
530
  }));
553
531
  }
554
- async addMfaMethod(username, method) {
555
- await this.store.withCas(username, (user) => {
532
+ async addMfaMethod(id, method) {
533
+ await this.store.withCas(id, (user) => {
556
534
  return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
557
535
  });
558
536
  }
559
- async confirmMfaMethod(username, name) {
560
- await this.store.withCas(username, (user) => {
537
+ async confirmMfaMethod(id, name) {
538
+ await this.store.withCas(id, (user) => {
561
539
  let found = false;
562
540
  const methods = user.mfa.methods.map((m) => {
563
541
  if (m.name === name) {
@@ -573,22 +551,22 @@ var UserService = class {
573
551
  return { set: { mfa: { methods } } };
574
552
  });
575
553
  }
576
- async removeMfaMethod(username, name) {
577
- const user = await this.getUser(username);
554
+ async removeMfaMethod(id, name) {
555
+ const user = await this.getUser(id);
578
556
  const update = { mfa: { methods: user.mfa.methods.filter((m) => m.name !== name) } };
579
557
  if (user.mfa.defaultMethod === name) update.mfa.defaultMethod = "";
580
- await this.store.update(username, { set: update });
558
+ await this.store.update(id, { set: update });
581
559
  }
582
- async setDefaultMfaMethod(username, name) {
583
- const user = await this.getUser(username);
560
+ async setDefaultMfaMethod(id, name) {
561
+ const user = await this.getUser(id);
584
562
  if (name && !user.mfa.methods.some((m) => m.name === name)) throw new UserAuthError("MFA_NOT_CONFIGURED");
585
- await this.store.update(username, { set: { mfa: {
563
+ await this.store.update(id, { set: { mfa: {
586
564
  defaultMethod: name,
587
565
  autoSend: false
588
566
  } } });
589
567
  }
590
- async setMfaAutoSend(username, value) {
591
- if (!await this.store.update(username, { set: { mfa: { autoSend: value } } })) throw new UserAuthError("NOT_FOUND");
568
+ async setMfaAutoSend(id, value) {
569
+ if (!await this.store.update(id, { set: { mfa: { autoSend: value } } })) throw new UserAuthError("NOT_FOUND");
592
570
  }
593
571
  getAvailableMfaMethods(mfa) {
594
572
  return mfa.methods.filter((m) => m.confirmed).map((m) => ({
@@ -598,61 +576,22 @@ var UserService = class {
598
576
  }));
599
577
  }
600
578
  /**
601
- * Generate `count` plaintext backup codes (default 10), persist their
602
- * hashes (replacing any existing batch), and return the plaintext codes
603
- * once for the caller to deliver to the user. Plaintext is never
604
- * recoverable after this call returns.
605
- *
606
- * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
607
- */
608
- async generateBackupCodes(username, count = 10) {
609
- const codes = generateBackupCodePlaintext(count);
610
- const hashes = codes.map(hashMfaCode);
611
- if (!await this.store.update(username, { set: { backupCodes: hashes } })) throw new UserAuthError("NOT_FOUND");
612
- return codes;
613
- }
614
- /**
615
- * Consume a backup code: returns `true` and removes the matching hash
616
- * from storage if `code` matches a stored backup code; returns `false`
617
- * if no match (without modifying storage). Single-use is enforced by
618
- * optimistic-concurrency CAS on the version column — concurrent consumes
619
- * of the same code race fairly and only one wins; the loser re-reads,
620
- * finds the hash already removed, and returns `false`.
621
- *
622
- * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
623
- * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
624
- */
625
- async consumeBackupCode(username, code) {
626
- let consumed = false;
627
- await this.store.withCas(username, (user) => {
628
- const hashes = user.backupCodes ?? [];
629
- const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
630
- if (idx < 0) {
631
- consumed = false;
632
- return null;
633
- }
634
- consumed = true;
635
- return { set: { backupCodes: hashes.filter((_, i) => i !== idx) } };
636
- });
637
- return consumed;
638
- }
639
- /**
640
579
  * Verify a TOTP code against the user's confirmed `totp` MFA method.
641
580
  * Failures bump the same `failedLoginAttempts` counter as `login` so an
642
581
  * attacker who knows the password but not the TOTP gets `lockout.threshold`
643
582
  * total tries across BOTH factors, not `2 * threshold`.
644
583
  */
645
- async verifyMfa(username, code, config) {
646
- const user = await this.getUser(username);
584
+ async verifyMfa(id, code, config, lockoutOverride) {
585
+ const user = await this.getUser(id);
647
586
  if (!user.account.active) throw new UserAuthError("INACTIVE");
648
- await this.ensureNotLockedOrThrow(username, user.account);
587
+ await this.ensureNotLockedOrThrow(id, user.account);
649
588
  const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
650
589
  if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
651
590
  const matchedCounter = verifyTotpCode(totp.value, code, config);
652
591
  const isReplay = matchedCounter !== null && totp.lastUsedWindow !== void 0 && matchedCounter <= totp.lastUsedWindow;
653
592
  if (matchedCounter !== null && !isReplay) {
654
593
  let replayDuringCas = false;
655
- await this.store.withCas(username, (current) => {
594
+ await this.store.withCas(id, (current) => {
656
595
  const currentTotp = current.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
657
596
  if (currentTotp?.lastUsedWindow !== void 0 && matchedCounter <= currentTotp.lastUsedWindow) {
658
597
  replayDuringCas = true;
@@ -667,7 +606,7 @@ var UserService = class {
667
606
  });
668
607
  if (!replayDuringCas) return;
669
608
  }
670
- await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
609
+ await this.incrementAndMaybeLock(id, user.account, "MFA_INVALID", lockoutOverride);
671
610
  }
672
611
  /**
673
612
  * Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
@@ -680,8 +619,8 @@ var UserService = class {
680
619
  * Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
681
620
  * totp method; MFA_INVALID on wrong code.
682
621
  */
683
- async verifyTotpSetupCode(username, code, config) {
684
- const totp = (await this.getUser(username)).mfa.methods.find((m) => m.name === "totp" && !m.confirmed);
622
+ async verifyTotpSetupCode(id, code, config) {
623
+ const totp = (await this.getUser(id)).mfa.methods.find((m) => m.name === "totp" && !m.confirmed);
685
624
  if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
686
625
  if (verifyTotpCode(totp.value, code, config) === null) throw new UserAuthError("MFA_INVALID");
687
626
  }
@@ -713,8 +652,8 @@ var UserService = class {
713
652
  * write — the array shape is preserved end-to-end so DB adapters with a
714
653
  * merge strategy replace the whole array.
715
654
  */
716
- async addTrustedDevice(username, record) {
717
- await this.store.withCas(username, (user) => {
655
+ async addTrustedDevice(id, record) {
656
+ await this.store.withCas(id, (user) => {
718
657
  return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
719
658
  });
720
659
  }
@@ -723,13 +662,13 @@ var UserService = class {
723
662
  * the configured secret, AND (b) matches a persisted record that is still
724
663
  * within its expiry window and whose bound IP (if any) matches.
725
664
  */
726
- async verifyTrustedDevice(username, token, ip) {
665
+ async verifyTrustedDevice(userId, token, ip) {
727
666
  const secret = this.requireDeviceTrustSecret();
728
667
  const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
729
668
  if (sepIdx <= 0) return false;
730
669
  const raw = token.slice(0, sepIdx);
731
- if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${username}|${raw}|${ip ?? ""}`))) return false;
732
- const user = await this.store.findByUsername(username);
670
+ if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${userId}|${raw}|${ip ?? ""}`))) return false;
671
+ const user = await this.store.findById(userId);
733
672
  if (!user) return false;
734
673
  const list = user.trustedDevices ?? [];
735
674
  const now = this.config.clock();
@@ -742,23 +681,23 @@ var UserService = class {
742
681
  * Remove a specific trust record from the user. No-op when the record is
743
682
  * absent — mirrors the legacy `DeviceTrustStore.revoke` semantics.
744
683
  */
745
- async revokeTrustedDevice(username, token) {
746
- const user = await this.store.findByUsername(username);
684
+ async revokeTrustedDevice(id, token) {
685
+ const user = await this.store.findById(id);
747
686
  if (!user) return;
748
687
  const list = user.trustedDevices ?? [];
749
688
  const next = list.filter((r) => r.token !== token);
750
689
  if (next.length === list.length) return;
751
- await this.store.update(username, { set: { trustedDevices: next } });
690
+ await this.store.update(id, { set: { trustedDevices: next } });
752
691
  }
753
- async listTrustedDevices(username) {
754
- return (await this.getUser(username)).trustedDevices ?? [];
692
+ async listTrustedDevices(id) {
693
+ return (await this.getUser(id)).trustedDevices ?? [];
755
694
  }
756
695
  requireDeviceTrustSecret() {
757
696
  const secret = this.config.deviceTrust?.secret;
758
697
  if (!secret) throw new Error("UserService: deviceTrust.secret is required to use trusted-device APIs");
759
698
  return secret;
760
699
  }
761
- async applyPasswordChange(username, user, newPassword) {
700
+ async applyPasswordChange(id, user, newPassword) {
762
701
  const policyResult = await this.checkPolicies(newPassword, user.password);
763
702
  if (!policyResult.passed) throw new UserAuthError("POLICY_VIOLATION", policyResult.errors.join("; "), { policies: policyResult.policies });
764
703
  const hashesToCheck = [user.password.hash, ...user.password.history].filter(Boolean);
@@ -768,7 +707,7 @@ var UserService = class {
768
707
  const newHash = await this.hasher.hash(newPassword);
769
708
  const limit = this.config.password.historyLength;
770
709
  const newHistory = limit > 0 ? [user.password.hash, ...user.password.history].filter(Boolean).slice(0, limit) : [];
771
- await this.store.update(username, { set: { password: {
710
+ await this.store.update(id, { set: { password: {
772
711
  hash: newHash,
773
712
  history: newHistory,
774
713
  lastChanged: this.config.clock(),
@@ -782,11 +721,11 @@ var UserService = class {
782
721
  * If `account.locked`: auto-unlock when the lock has expired (mutating
783
722
  * `account` in place), or throw `LOCKED` otherwise.
784
723
  */
785
- async ensureNotLockedOrThrow(username, account) {
724
+ async ensureNotLockedOrThrow(id, account) {
786
725
  const lockStatus = this.getLockStatus(account);
787
726
  if (!lockStatus.locked) return;
788
727
  if (lockStatus.expired) {
789
- await this.store.update(username, { set: { account: {
728
+ await this.store.update(id, { set: { account: {
790
729
  locked: false,
791
730
  lockReason: "",
792
731
  lockEnds: 0
@@ -806,13 +745,21 @@ var UserService = class {
806
745
  * and always throw `errorCode` (with `details.lockEnds` when the lockout
807
746
  * just tripped). Used by both `login` and `verifyMfa` so the two factors
808
747
  * share one counter.
748
+ *
749
+ * `lockoutOverride` lets a caller (e.g. a workflow policy resolver) force a
750
+ * different posture for THIS lock — notably `{ duration: 0 }` to make the
751
+ * lock permanent (admin-/recovery-lift only) instead of timed. Unset fields
752
+ * fall back to `this.config.lockout`.
809
753
  */
810
- async incrementAndMaybeLock(username, account, errorCode) {
754
+ async incrementAndMaybeLock(id, account, errorCode, lockoutOverride) {
811
755
  const newAttempts = account.failedLoginAttempts + 1;
812
- const { threshold, duration } = this.config.lockout;
756
+ const { threshold, duration } = {
757
+ ...this.config.lockout,
758
+ ...lockoutOverride
759
+ };
813
760
  if (threshold > 0 && newAttempts >= threshold) {
814
761
  const lockEnds = duration ? this.config.clock() + duration : 0;
815
- await this.store.update(username, {
762
+ await this.store.update(id, {
816
763
  inc: { "account.failedLoginAttempts": 1 },
817
764
  set: { account: {
818
765
  locked: true,
@@ -822,51 +769,118 @@ var UserService = class {
822
769
  });
823
770
  throw new UserAuthError(errorCode, void 0, { lockEnds });
824
771
  }
825
- await this.store.update(username, { inc: { "account.failedLoginAttempts": 1 } });
772
+ await this.store.update(id, { inc: { "account.failedLoginAttempts": 1 } });
826
773
  throw new UserAuthError(errorCode);
827
774
  }
828
775
  };
829
776
  //#endregion
830
777
  //#region src/store/memory.ts
831
778
  var UserStoreMemory = class extends UserStore {
779
+ /** Keyed by the stable surrogate `id` (the token subject). */
832
780
  store = /* @__PURE__ */ new Map();
833
- constructor(seed) {
781
+ /**
782
+ * Ordered secondary handle fields (e.g. email then phone) resolved from the
783
+ * model's `@aooth.user.*` annotations by the wiring layer (`handleFields` of
784
+ * `getAoothUserHandleSpec`). Tried by `findByHandle` after `username`, and
785
+ * enforced unique by `create`. Empty when no handles are configured (login by
786
+ * email/phone unavailable).
787
+ */
788
+ handleFields;
789
+ /**
790
+ * Optional seed. The map is keyed by each record's `id`; a record missing an
791
+ * `id` gets one minted (mirrors the DB `@db.default.uuid`). The seed object's
792
+ * keys are ignored — identity is the record's `id`.
793
+ *
794
+ * `opts.handleFields` names the ordered secondary login handles (mirroring
795
+ * `UsersStoreAtscriptDb`); omit it for username-only resolution.
796
+ */
797
+ constructor(seed, opts) {
834
798
  super();
835
- if (seed) for (const [key, value] of Object.entries(seed)) this.store.set(key, structuredClone(value));
799
+ this.handleFields = opts?.handleFields ?? [];
800
+ if (seed) for (const value of Object.values(seed)) {
801
+ const cloned = structuredClone(value);
802
+ if (!cloned.id) cloned.id = randomUUID();
803
+ this.store.set(cloned.id, cloned);
804
+ }
836
805
  }
837
- async exists(username) {
838
- return this.store.has(username);
806
+ async exists(handle) {
807
+ for (const u of this.store.values()) if (u.username === handle) return true;
808
+ return false;
839
809
  }
840
- async findByUsername(username) {
841
- const user = this.store.get(username);
810
+ async findById(id) {
811
+ const user = this.store.get(id);
842
812
  return user ? structuredClone(user) : null;
843
813
  }
814
+ async findByHandle(handle) {
815
+ let byHandle = null;
816
+ for (const u of this.store.values()) {
817
+ if (u.username === handle) return structuredClone(u);
818
+ if (byHandle === null) {
819
+ const rec = u;
820
+ for (const field of this.handleFields) {
821
+ const value = rec[field];
822
+ if (value !== void 0 && value === handle) {
823
+ byHandle = u;
824
+ break;
825
+ }
826
+ }
827
+ }
828
+ }
829
+ return byHandle ? structuredClone(byHandle) : null;
830
+ }
831
+ async findByIdentifier(value) {
832
+ const byId = this.store.get(value);
833
+ if (byId) return structuredClone(byId);
834
+ return this.findByHandle(value);
835
+ }
844
836
  async create(data) {
845
- if (this.store.has(data.username)) throw new UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
846
837
  const cloned = structuredClone(data);
838
+ if (!cloned.id) cloned.id = randomUUID();
839
+ for (const u of this.store.values()) if (u.username === cloned.username) throw new UserAuthError("ALREADY_EXISTS", `User "${cloned.username}" already exists`);
840
+ this.assertHandlesUnique(cloned);
847
841
  cloned.version = 0;
848
- this.store.set(data.username, cloned);
842
+ this.store.set(cloned.id, cloned);
843
+ }
844
+ /**
845
+ * Throw `ALREADY_EXISTS` if any record other than `excludeId` already holds
846
+ * one of `rec`'s handle-column values — the in-memory mirror of the
847
+ * atscript-db store's unique index, so `create` and `update` enforce the same
848
+ * collision contract (which `promote-to-handle` swallows best-effort). Handle
849
+ * values are string columns; a non-string is never a collision.
850
+ */
851
+ assertHandlesUnique(rec, excludeId) {
852
+ for (const field of this.handleFields) {
853
+ const value = rec[field];
854
+ if (typeof value !== "string") continue;
855
+ for (const [otherId, other] of this.store) {
856
+ if (otherId === excludeId) continue;
857
+ if (other[field] === value) throw new UserAuthError("ALREADY_EXISTS", `${field} "${value}" already exists`);
858
+ }
859
+ }
849
860
  }
850
- async update(username, update) {
851
- const user = this.store.get(username);
861
+ async update(id, update) {
862
+ const user = this.store.get(id);
852
863
  if (!user) return false;
853
864
  if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
854
- if (update.set) deepMerge(user, update.set);
865
+ if (update.set) {
866
+ this.assertHandlesUnique(update.set, id);
867
+ deepMerge(user, update.set);
868
+ }
855
869
  if (update.inc) for (const [path, amount] of Object.entries(update.inc)) incrementAtPath(user, path, amount);
856
870
  user.version = (user.version ?? 0) + 1;
857
871
  return true;
858
872
  }
859
- async delete(username) {
860
- return this.store.delete(username);
873
+ async delete(id) {
874
+ return this.store.delete(id);
861
875
  }
862
- async withCas(username, mutator, opts) {
876
+ async withCas(id, mutator, opts) {
863
877
  const maxAttempts = opts?.maxAttempts ?? 2;
864
878
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
865
- const current = await this.findByUsername(username);
879
+ const current = await this.findById(id);
866
880
  if (!current) throw new UserAuthError("NOT_FOUND");
867
881
  const patch = mutator(current);
868
882
  if (patch === null) return;
869
- if (await this.update(username, {
883
+ if (await this.update(id, {
870
884
  ...patch,
871
885
  expectedVersion: current.version ?? 0
872
886
  })) return;
@@ -875,6 +889,65 @@ var UserStoreMemory = class extends UserStore {
875
889
  }
876
890
  };
877
891
  //#endregion
892
+ //#region src/store/federated-identity-store-memory.ts
893
+ /**
894
+ * In-memory {@link FederatedIdentityStore} — the offline-testable reference
895
+ * impl (RFC IDP.md §9). Keyed by the composite `(provider, subject)`;
896
+ * `structuredClone` on every read/write isolates callers from later mutation
897
+ * (mirrors `UserStoreMemory`).
898
+ */
899
+ var FederatedIdentityStoreMemory = class extends FederatedIdentityStore {
900
+ /** Keyed by `compositeKey(provider, subject)`. */
901
+ store = /* @__PURE__ */ new Map();
902
+ clock;
903
+ constructor(opts) {
904
+ super();
905
+ this.clock = opts?.clock ?? Date.now;
906
+ }
907
+ async find(provider, subject) {
908
+ const row = this.store.get(compositeKey(provider, subject));
909
+ return row ? structuredClone(row) : null;
910
+ }
911
+ async listForUser(userId) {
912
+ return [...this.store.values()].filter((row) => row.userId === userId).toSorted((a, b) => a.linkedAt - b.linkedAt).map((row) => structuredClone(row));
913
+ }
914
+ async link(rec) {
915
+ const key = compositeKey(rec.provider, rec.subject);
916
+ if (this.store.has(key)) throw new UserAuthError("ALREADY_EXISTS", `Provider account "${rec.provider}:${rec.subject}" is already linked`);
917
+ const row = {
918
+ id: randomUUID(),
919
+ provider: rec.provider,
920
+ subject: rec.subject,
921
+ userId: rec.userId,
922
+ ...pickDefinedProfile(rec),
923
+ linkedAt: this.clock()
924
+ };
925
+ this.store.set(key, structuredClone(row));
926
+ return structuredClone(row);
927
+ }
928
+ async unlink(provider, subject) {
929
+ return this.store.delete(compositeKey(provider, subject));
930
+ }
931
+ async touchLogin(provider, subject, profile) {
932
+ const row = this.store.get(compositeKey(provider, subject));
933
+ if (!row) return;
934
+ row.lastLoginAt = this.clock();
935
+ if (profile) Object.assign(row, pickDefinedProfile(profile));
936
+ }
937
+ async deleteAllForUser(userId) {
938
+ let removed = 0;
939
+ for (const [key, row] of this.store) if (row.userId === userId) {
940
+ this.store.delete(key);
941
+ removed++;
942
+ }
943
+ return removed;
944
+ }
945
+ };
946
+ /** Null-byte separator can't appear in a provider id / IdP subject, so the join is unambiguous. */
947
+ function compositeKey(provider, subject) {
948
+ return `${provider}${subject}`;
949
+ }
950
+ //#endregion
878
951
  //#region src/password/policies.ts
879
952
  const ppHasMinLength = (min = 8) => definePasswordPolicy({
880
953
  rule: (v, min) => v.length >= min,
@@ -913,4 +986,30 @@ const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
913
986
  errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
914
987
  });
915
988
  //#endregion
916
- export { PasswordHasher, PasswordPolicy, UserAuthError, UserService, UserStore, UserStoreMemory, definePasswordPolicy, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
989
+ //#region src/mfa/codes.ts
990
+ /**
991
+ * SHA-256 hash of an MFA code (e.g. one-time email/SMS code or backup code).
992
+ *
993
+ * Hex-encoded for stable, comparable output regardless of input case/format.
994
+ * Use {@link verifyMfaCode} to compare a submitted plaintext code against the
995
+ * stored hash in constant time.
996
+ */
997
+ function hashMfaCode(code) {
998
+ return createHash("sha256").update(code).digest("hex");
999
+ }
1000
+ /**
1001
+ * Constant-time comparison of a submitted plaintext code against an
1002
+ * expected SHA-256 hex hash (as produced by {@link hashMfaCode}).
1003
+ *
1004
+ * Returns false for malformed/empty expected hashes (timingSafeEqual
1005
+ * requires equal-length, non-empty buffers).
1006
+ */
1007
+ function verifyMfaCode(submitted, expectedHash) {
1008
+ if (!expectedHash) return false;
1009
+ const a = Buffer.from(hashMfaCode(submitted), "hex");
1010
+ const b = Buffer.from(expectedHash, "hex");
1011
+ if (a.length !== b.length) return false;
1012
+ return timingSafeEqual(a, b);
1013
+ }
1014
+ //#endregion
1015
+ export { FederatedIdentityStore, FederatedIdentityStoreMemory, PasswordHasher, PasswordPolicy, UserAuthError, UserService, UserStore, UserStoreMemory, definePasswordPolicy, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, pickDefinedProfile, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };