@aooth/user 0.1.7 → 0.1.8

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