@aooth/user 0.1.6 → 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.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-Cmc7jBrw.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);
@@ -337,7 +282,8 @@ function resolveConfig(config) {
337
282
  scryptR: config?.password?.scryptR ?? 8,
338
283
  scryptP: config?.password?.scryptP ?? 1,
339
284
  keyLength: config?.password?.keyLength ?? 64,
340
- policies: normalizePolicies(config?.password?.policies)
285
+ policies: normalizePolicies(config?.password?.policies),
286
+ maxAgeMs: config?.password?.maxAgeMs ?? 0
341
287
  },
342
288
  lockout: {
343
289
  threshold: config?.lockout?.threshold ?? 0,
@@ -358,7 +304,17 @@ function deviceTrustSafeEqual(a, b) {
358
304
  if (ab.length !== bb.length) return false;
359
305
  return timingSafeEqual(ab, bb);
360
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
+ */
361
316
  var UserService = class {
317
+ store;
362
318
  config;
363
319
  hasher;
364
320
  constructor(store, config) {
@@ -370,25 +326,31 @@ var UserService = class {
370
326
  * Creates a user with `account.active: false`. The invite workflow relies
371
327
  * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
372
328
  * inactive until they accept). For setup scripts / seeders / tests that
373
- * don't go through invite, follow up with `activateAccount(username)` or
374
- * `login()` will throw `UserAuthError("INACTIVE")` — which the login
375
- * 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
376
332
  * enumeration, so the failure is silent client-side.
377
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
+ *
378
338
  * @param extras Optional partial user fields merged AFTER the base
379
339
  * `UserCredentials` shape, so callers can populate consumer-specific
380
340
  * required fields (e.g. `tenantId`) without subclassing the store.
381
341
  * Because the merge is shallow and extras win, overlapping top-level
382
- * keys (`id`, `account`, `mfa`, ...) replace the defaults entirely —
383
- * pass nested objects with all required sub-fields if you intend to
384
- * 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.
385
345
  */
386
346
  async createUser(username, password, extras) {
387
347
  const pw = password ?? this.hasher.generatePassword();
348
+ const hash = await this.hasher.hash(pw);
388
349
  const userData = {
350
+ id: randomUUID(),
389
351
  username,
390
352
  password: {
391
- hash: await this.hasher.hash(pw),
353
+ hash,
392
354
  history: [],
393
355
  lastChanged: this.config.clock(),
394
356
  isInitial: !password
@@ -411,19 +373,36 @@ var UserService = class {
411
373
  await this.store.create(userData);
412
374
  return userData;
413
375
  }
414
- async getUser(username) {
415
- 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);
416
379
  if (!user) throw new UserAuthError("NOT_FOUND");
417
380
  return user;
418
381
  }
419
- async login(username, password) {
420
- 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);
421
400
  if (!user) throw new UserAuthError("NOT_FOUND");
422
401
  if (!user.account.active) throw new UserAuthError("INACTIVE");
423
- await this.ensureNotLockedOrThrow(username, user.account);
402
+ await this.ensureNotLockedOrThrow(user.id, user.account);
424
403
  if (await this.hasher.verify(password, user.password.hash)) {
425
404
  const now = this.config.clock();
426
- await this.store.update(username, { set: { account: {
405
+ await this.store.update(user.id, { set: { account: {
427
406
  lastLogin: now,
428
407
  failedLoginAttempts: 0
429
408
  } } });
@@ -434,30 +413,30 @@ var UserService = class {
434
413
  mfaRequired: this.hasConfirmedMfaMethods(user.mfa)
435
414
  };
436
415
  }
437
- return this.incrementAndMaybeLock(username, user.account, "INVALID_CREDENTIALS");
416
+ return this.incrementAndMaybeLock(user.id, user.account, "INVALID_CREDENTIALS", lockoutOverride);
438
417
  }
439
- async verifyPassword(username, password) {
440
- const user = await this.store.findByUsername(username);
418
+ async verifyPassword(id, password) {
419
+ const user = await this.store.findById(id);
441
420
  if (!user) throw new UserAuthError("NOT_FOUND");
442
421
  return this.hasher.verify(password, user.password.hash);
443
422
  }
444
- async changePassword(username, currentPassword, newPassword, repeatPassword) {
423
+ async changePassword(id, currentPassword, newPassword, repeatPassword) {
445
424
  if (repeatPassword !== void 0 && newPassword !== repeatPassword) throw new UserAuthError("PASSWORDS_MISMATCH");
446
- const user = await this.getUser(username);
425
+ const user = await this.getUser(id);
447
426
  if (!await this.hasher.verify(currentPassword, user.password.hash)) throw new UserAuthError("INVALID_CREDENTIALS");
448
- await this.applyPasswordChange(username, user, newPassword);
427
+ await this.applyPasswordChange(id, user, newPassword);
449
428
  }
450
- async setPassword(username, newPassword) {
451
- const user = await this.getUser(username);
452
- 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);
453
432
  }
454
433
  /**
455
- * Hard-delete the user row. Returns nothing on success. Throws
456
- * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
457
- * 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.
458
437
  */
459
- async deleteUser(username) {
460
- 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");
461
440
  }
462
441
  /**
463
442
  * Deep-merge `patch` into the user record (top-level fields are shallow-
@@ -465,26 +444,26 @@ var UserService = class {
465
444
  * `@db.patch.strategy 'merge'` declaration). Returns the patched record.
466
445
  * Used by the invite workflow's `applyProfile` default fallback.
467
446
  */
468
- async update(username, patch) {
469
- if (!await this.store.update(username, { set: patch })) throw new UserAuthError("NOT_FOUND");
470
- 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);
471
450
  }
472
- async activateAccount(username) {
473
- 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");
474
453
  }
475
- async deactivateAccount(username) {
476
- 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");
477
456
  }
478
- async lockAccount(username, reason, duration) {
457
+ async lockAccount(id, reason, duration) {
479
458
  const lockEnds = duration ? this.config.clock() + duration : 0;
480
- if (!await this.store.update(username, { set: { account: {
459
+ if (!await this.store.update(id, { set: { account: {
481
460
  locked: true,
482
461
  lockReason: reason,
483
462
  lockEnds
484
463
  } } })) throw new UserAuthError("NOT_FOUND");
485
464
  }
486
- async unlockAccount(username) {
487
- if (!await this.store.update(username, { set: { account: {
465
+ async unlockAccount(id) {
466
+ if (!await this.store.update(id, { set: { account: {
488
467
  locked: false,
489
468
  lockReason: "",
490
469
  lockEnds: 0,
@@ -505,6 +484,24 @@ var UserService = class {
505
484
  lockEnds: account.lockEnds
506
485
  };
507
486
  }
487
+ /**
488
+ * Returns `true` when the user's password is older than
489
+ * `config.password.maxAgeMs`. Returns `false` when:
490
+ * - `maxAgeMs` is unset or `0` (expiry disabled)
491
+ * - `password.lastChanged` is `0` / falsy (no recorded change — never
492
+ * expire a user whose timestamp wasn't captured, since that would
493
+ * force-loop on every login)
494
+ *
495
+ * Consulted by `@aooth/auth-moost` `LoginWorkflow`'s `credentials`
496
+ * step to set `ctx.isPasswordExpired` when `guards.passwordExpiry`
497
+ * is true (the default).
498
+ */
499
+ isPasswordExpired(user, now = this.config.clock()) {
500
+ const maxAgeMs = this.config.password.maxAgeMs;
501
+ const lastChanged = user.password.lastChanged;
502
+ if (!maxAgeMs || !lastChanged) return false;
503
+ return now - lastChanged > maxAgeMs;
504
+ }
508
505
  async checkPolicies(password, passwordData) {
509
506
  const result = {
510
507
  passed: true,
@@ -532,13 +529,13 @@ var UserService = class {
532
529
  errorMessage: p.errorMessage
533
530
  }));
534
531
  }
535
- async addMfaMethod(username, method) {
536
- await this.store.withCas(username, (user) => {
532
+ async addMfaMethod(id, method) {
533
+ await this.store.withCas(id, (user) => {
537
534
  return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
538
535
  });
539
536
  }
540
- async confirmMfaMethod(username, name) {
541
- await this.store.withCas(username, (user) => {
537
+ async confirmMfaMethod(id, name) {
538
+ await this.store.withCas(id, (user) => {
542
539
  let found = false;
543
540
  const methods = user.mfa.methods.map((m) => {
544
541
  if (m.name === name) {
@@ -554,22 +551,22 @@ var UserService = class {
554
551
  return { set: { mfa: { methods } } };
555
552
  });
556
553
  }
557
- async removeMfaMethod(username, name) {
558
- const user = await this.getUser(username);
554
+ async removeMfaMethod(id, name) {
555
+ const user = await this.getUser(id);
559
556
  const update = { mfa: { methods: user.mfa.methods.filter((m) => m.name !== name) } };
560
557
  if (user.mfa.defaultMethod === name) update.mfa.defaultMethod = "";
561
- await this.store.update(username, { set: update });
558
+ await this.store.update(id, { set: update });
562
559
  }
563
- async setDefaultMfaMethod(username, name) {
564
- const user = await this.getUser(username);
560
+ async setDefaultMfaMethod(id, name) {
561
+ const user = await this.getUser(id);
565
562
  if (name && !user.mfa.methods.some((m) => m.name === name)) throw new UserAuthError("MFA_NOT_CONFIGURED");
566
- await this.store.update(username, { set: { mfa: {
563
+ await this.store.update(id, { set: { mfa: {
567
564
  defaultMethod: name,
568
565
  autoSend: false
569
566
  } } });
570
567
  }
571
- async setMfaAutoSend(username, value) {
572
- 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");
573
570
  }
574
571
  getAvailableMfaMethods(mfa) {
575
572
  return mfa.methods.filter((m) => m.confirmed).map((m) => ({
@@ -579,61 +576,22 @@ var UserService = class {
579
576
  }));
580
577
  }
581
578
  /**
582
- * Generate `count` plaintext backup codes (default 10), persist their
583
- * hashes (replacing any existing batch), and return the plaintext codes
584
- * once for the caller to deliver to the user. Plaintext is never
585
- * recoverable after this call returns.
586
- *
587
- * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
588
- */
589
- async generateBackupCodes(username, count = 10) {
590
- const codes = generateBackupCodePlaintext(count);
591
- const hashes = codes.map(hashMfaCode);
592
- if (!await this.store.update(username, { set: { backupCodes: hashes } })) throw new UserAuthError("NOT_FOUND");
593
- return codes;
594
- }
595
- /**
596
- * Consume a backup code: returns `true` and removes the matching hash
597
- * from storage if `code` matches a stored backup code; returns `false`
598
- * if no match (without modifying storage). Single-use is enforced by
599
- * optimistic-concurrency CAS on the version column — concurrent consumes
600
- * of the same code race fairly and only one wins; the loser re-reads,
601
- * finds the hash already removed, and returns `false`.
602
- *
603
- * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
604
- * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
605
- */
606
- async consumeBackupCode(username, code) {
607
- let consumed = false;
608
- await this.store.withCas(username, (user) => {
609
- const hashes = user.backupCodes ?? [];
610
- const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
611
- if (idx < 0) {
612
- consumed = false;
613
- return null;
614
- }
615
- consumed = true;
616
- return { set: { backupCodes: hashes.filter((_, i) => i !== idx) } };
617
- });
618
- return consumed;
619
- }
620
- /**
621
579
  * Verify a TOTP code against the user's confirmed `totp` MFA method.
622
580
  * Failures bump the same `failedLoginAttempts` counter as `login` so an
623
581
  * attacker who knows the password but not the TOTP gets `lockout.threshold`
624
582
  * total tries across BOTH factors, not `2 * threshold`.
625
583
  */
626
- async verifyMfa(username, code, config) {
627
- const user = await this.getUser(username);
584
+ async verifyMfa(id, code, config, lockoutOverride) {
585
+ const user = await this.getUser(id);
628
586
  if (!user.account.active) throw new UserAuthError("INACTIVE");
629
- await this.ensureNotLockedOrThrow(username, user.account);
587
+ await this.ensureNotLockedOrThrow(id, user.account);
630
588
  const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
631
589
  if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
632
590
  const matchedCounter = verifyTotpCode(totp.value, code, config);
633
591
  const isReplay = matchedCounter !== null && totp.lastUsedWindow !== void 0 && matchedCounter <= totp.lastUsedWindow;
634
592
  if (matchedCounter !== null && !isReplay) {
635
593
  let replayDuringCas = false;
636
- await this.store.withCas(username, (current) => {
594
+ await this.store.withCas(id, (current) => {
637
595
  const currentTotp = current.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
638
596
  if (currentTotp?.lastUsedWindow !== void 0 && matchedCounter <= currentTotp.lastUsedWindow) {
639
597
  replayDuringCas = true;
@@ -648,7 +606,7 @@ var UserService = class {
648
606
  });
649
607
  if (!replayDuringCas) return;
650
608
  }
651
- await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
609
+ await this.incrementAndMaybeLock(id, user.account, "MFA_INVALID", lockoutOverride);
652
610
  }
653
611
  /**
654
612
  * Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
@@ -661,8 +619,8 @@ var UserService = class {
661
619
  * Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
662
620
  * totp method; MFA_INVALID on wrong code.
663
621
  */
664
- async verifyTotpSetupCode(username, code, config) {
665
- 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);
666
624
  if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
667
625
  if (verifyTotpCode(totp.value, code, config) === null) throw new UserAuthError("MFA_INVALID");
668
626
  }
@@ -694,8 +652,8 @@ var UserService = class {
694
652
  * write — the array shape is preserved end-to-end so DB adapters with a
695
653
  * merge strategy replace the whole array.
696
654
  */
697
- async addTrustedDevice(username, record) {
698
- await this.store.withCas(username, (user) => {
655
+ async addTrustedDevice(id, record) {
656
+ await this.store.withCas(id, (user) => {
699
657
  return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
700
658
  });
701
659
  }
@@ -704,13 +662,13 @@ var UserService = class {
704
662
  * the configured secret, AND (b) matches a persisted record that is still
705
663
  * within its expiry window and whose bound IP (if any) matches.
706
664
  */
707
- async verifyTrustedDevice(username, token, ip) {
665
+ async verifyTrustedDevice(userId, token, ip) {
708
666
  const secret = this.requireDeviceTrustSecret();
709
667
  const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
710
668
  if (sepIdx <= 0) return false;
711
669
  const raw = token.slice(0, sepIdx);
712
- if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${username}|${raw}|${ip ?? ""}`))) return false;
713
- 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);
714
672
  if (!user) return false;
715
673
  const list = user.trustedDevices ?? [];
716
674
  const now = this.config.clock();
@@ -723,23 +681,23 @@ var UserService = class {
723
681
  * Remove a specific trust record from the user. No-op when the record is
724
682
  * absent — mirrors the legacy `DeviceTrustStore.revoke` semantics.
725
683
  */
726
- async revokeTrustedDevice(username, token) {
727
- const user = await this.store.findByUsername(username);
684
+ async revokeTrustedDevice(id, token) {
685
+ const user = await this.store.findById(id);
728
686
  if (!user) return;
729
687
  const list = user.trustedDevices ?? [];
730
688
  const next = list.filter((r) => r.token !== token);
731
689
  if (next.length === list.length) return;
732
- await this.store.update(username, { set: { trustedDevices: next } });
690
+ await this.store.update(id, { set: { trustedDevices: next } });
733
691
  }
734
- async listTrustedDevices(username) {
735
- return (await this.getUser(username)).trustedDevices ?? [];
692
+ async listTrustedDevices(id) {
693
+ return (await this.getUser(id)).trustedDevices ?? [];
736
694
  }
737
695
  requireDeviceTrustSecret() {
738
696
  const secret = this.config.deviceTrust?.secret;
739
697
  if (!secret) throw new Error("UserService: deviceTrust.secret is required to use trusted-device APIs");
740
698
  return secret;
741
699
  }
742
- async applyPasswordChange(username, user, newPassword) {
700
+ async applyPasswordChange(id, user, newPassword) {
743
701
  const policyResult = await this.checkPolicies(newPassword, user.password);
744
702
  if (!policyResult.passed) throw new UserAuthError("POLICY_VIOLATION", policyResult.errors.join("; "), { policies: policyResult.policies });
745
703
  const hashesToCheck = [user.password.hash, ...user.password.history].filter(Boolean);
@@ -749,7 +707,7 @@ var UserService = class {
749
707
  const newHash = await this.hasher.hash(newPassword);
750
708
  const limit = this.config.password.historyLength;
751
709
  const newHistory = limit > 0 ? [user.password.hash, ...user.password.history].filter(Boolean).slice(0, limit) : [];
752
- await this.store.update(username, { set: { password: {
710
+ await this.store.update(id, { set: { password: {
753
711
  hash: newHash,
754
712
  history: newHistory,
755
713
  lastChanged: this.config.clock(),
@@ -763,11 +721,11 @@ var UserService = class {
763
721
  * If `account.locked`: auto-unlock when the lock has expired (mutating
764
722
  * `account` in place), or throw `LOCKED` otherwise.
765
723
  */
766
- async ensureNotLockedOrThrow(username, account) {
724
+ async ensureNotLockedOrThrow(id, account) {
767
725
  const lockStatus = this.getLockStatus(account);
768
726
  if (!lockStatus.locked) return;
769
727
  if (lockStatus.expired) {
770
- await this.store.update(username, { set: { account: {
728
+ await this.store.update(id, { set: { account: {
771
729
  locked: false,
772
730
  lockReason: "",
773
731
  lockEnds: 0
@@ -787,13 +745,21 @@ var UserService = class {
787
745
  * and always throw `errorCode` (with `details.lockEnds` when the lockout
788
746
  * just tripped). Used by both `login` and `verifyMfa` so the two factors
789
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`.
790
753
  */
791
- async incrementAndMaybeLock(username, account, errorCode) {
754
+ async incrementAndMaybeLock(id, account, errorCode, lockoutOverride) {
792
755
  const newAttempts = account.failedLoginAttempts + 1;
793
- const { threshold, duration } = this.config.lockout;
756
+ const { threshold, duration } = {
757
+ ...this.config.lockout,
758
+ ...lockoutOverride
759
+ };
794
760
  if (threshold > 0 && newAttempts >= threshold) {
795
761
  const lockEnds = duration ? this.config.clock() + duration : 0;
796
- await this.store.update(username, {
762
+ await this.store.update(id, {
797
763
  inc: { "account.failedLoginAttempts": 1 },
798
764
  set: { account: {
799
765
  locked: true,
@@ -803,33 +769,61 @@ var UserService = class {
803
769
  });
804
770
  throw new UserAuthError(errorCode, void 0, { lockEnds });
805
771
  }
806
- await this.store.update(username, { inc: { "account.failedLoginAttempts": 1 } });
772
+ await this.store.update(id, { inc: { "account.failedLoginAttempts": 1 } });
807
773
  throw new UserAuthError(errorCode);
808
774
  }
809
775
  };
810
776
  //#endregion
811
777
  //#region src/store/memory.ts
812
778
  var UserStoreMemory = class extends UserStore {
779
+ /** Keyed by the stable surrogate `id` (the token subject). */
813
780
  store = /* @__PURE__ */ new Map();
781
+ /**
782
+ * Optional seed. The map is keyed by each record's `id`; a record missing an
783
+ * `id` gets one minted (mirrors the DB `@db.default.uuid`). The seed object's
784
+ * keys are ignored — identity is the record's `id`.
785
+ */
814
786
  constructor(seed) {
815
787
  super();
816
- if (seed) for (const [key, value] of Object.entries(seed)) this.store.set(key, structuredClone(value));
788
+ if (seed) for (const value of Object.values(seed)) {
789
+ const cloned = structuredClone(value);
790
+ if (!cloned.id) cloned.id = randomUUID();
791
+ this.store.set(cloned.id, cloned);
792
+ }
817
793
  }
818
- async exists(username) {
819
- return this.store.has(username);
794
+ async exists(handle) {
795
+ for (const u of this.store.values()) if (u.username === handle) return true;
796
+ return false;
820
797
  }
821
- async findByUsername(username) {
822
- const user = this.store.get(username);
798
+ async findById(id) {
799
+ const user = this.store.get(id);
823
800
  return user ? structuredClone(user) : null;
824
801
  }
802
+ async findByHandle(handle) {
803
+ let byEmail = null;
804
+ for (const u of this.store.values()) {
805
+ if (u.username === handle) return structuredClone(u);
806
+ if (byEmail === null && u.email !== void 0 && u.email === handle) byEmail = u;
807
+ }
808
+ return byEmail ? structuredClone(byEmail) : null;
809
+ }
810
+ async findByIdentifier(value) {
811
+ const byId = this.store.get(value);
812
+ if (byId) return structuredClone(byId);
813
+ return this.findByHandle(value);
814
+ }
825
815
  async create(data) {
826
- if (this.store.has(data.username)) throw new UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
827
816
  const cloned = structuredClone(data);
817
+ if (!cloned.id) cloned.id = randomUUID();
818
+ for (const u of this.store.values()) {
819
+ if (u.username === cloned.username) throw new UserAuthError("ALREADY_EXISTS", `User "${cloned.username}" already exists`);
820
+ if (cloned.email !== void 0 && u.email === cloned.email) throw new UserAuthError("ALREADY_EXISTS", `Email "${cloned.email}" already exists`);
821
+ }
828
822
  cloned.version = 0;
829
- this.store.set(data.username, cloned);
823
+ this.store.set(cloned.id, cloned);
830
824
  }
831
- async update(username, update) {
832
- const user = this.store.get(username);
825
+ async update(id, update) {
826
+ const user = this.store.get(id);
833
827
  if (!user) return false;
834
828
  if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
835
829
  if (update.set) deepMerge(user, update.set);
@@ -837,17 +831,17 @@ var UserStoreMemory = class extends UserStore {
837
831
  user.version = (user.version ?? 0) + 1;
838
832
  return true;
839
833
  }
840
- async delete(username) {
841
- return this.store.delete(username);
834
+ async delete(id) {
835
+ return this.store.delete(id);
842
836
  }
843
- async withCas(username, mutator, opts) {
837
+ async withCas(id, mutator, opts) {
844
838
  const maxAttempts = opts?.maxAttempts ?? 2;
845
839
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
846
- const current = await this.findByUsername(username);
840
+ const current = await this.findById(id);
847
841
  if (!current) throw new UserAuthError("NOT_FOUND");
848
842
  const patch = mutator(current);
849
843
  if (patch === null) return;
850
- if (await this.update(username, {
844
+ if (await this.update(id, {
851
845
  ...patch,
852
846
  expectedVersion: current.version ?? 0
853
847
  })) return;
@@ -856,6 +850,65 @@ var UserStoreMemory = class extends UserStore {
856
850
  }
857
851
  };
858
852
  //#endregion
853
+ //#region src/store/federated-identity-store-memory.ts
854
+ /**
855
+ * In-memory {@link FederatedIdentityStore} — the offline-testable reference
856
+ * impl (RFC IDP.md §9). Keyed by the composite `(provider, subject)`;
857
+ * `structuredClone` on every read/write isolates callers from later mutation
858
+ * (mirrors `UserStoreMemory`).
859
+ */
860
+ var FederatedIdentityStoreMemory = class extends FederatedIdentityStore {
861
+ /** Keyed by `compositeKey(provider, subject)`. */
862
+ store = /* @__PURE__ */ new Map();
863
+ clock;
864
+ constructor(opts) {
865
+ super();
866
+ this.clock = opts?.clock ?? Date.now;
867
+ }
868
+ async find(provider, subject) {
869
+ const row = this.store.get(compositeKey(provider, subject));
870
+ return row ? structuredClone(row) : null;
871
+ }
872
+ async listForUser(userId) {
873
+ return [...this.store.values()].filter((row) => row.userId === userId).toSorted((a, b) => a.linkedAt - b.linkedAt).map((row) => structuredClone(row));
874
+ }
875
+ async link(rec) {
876
+ const key = compositeKey(rec.provider, rec.subject);
877
+ if (this.store.has(key)) throw new UserAuthError("ALREADY_EXISTS", `Provider account "${rec.provider}:${rec.subject}" is already linked`);
878
+ const row = {
879
+ id: randomUUID(),
880
+ provider: rec.provider,
881
+ subject: rec.subject,
882
+ userId: rec.userId,
883
+ ...pickDefinedProfile(rec),
884
+ linkedAt: this.clock()
885
+ };
886
+ this.store.set(key, structuredClone(row));
887
+ return structuredClone(row);
888
+ }
889
+ async unlink(provider, subject) {
890
+ return this.store.delete(compositeKey(provider, subject));
891
+ }
892
+ async touchLogin(provider, subject, profile) {
893
+ const row = this.store.get(compositeKey(provider, subject));
894
+ if (!row) return;
895
+ row.lastLoginAt = this.clock();
896
+ if (profile) Object.assign(row, pickDefinedProfile(profile));
897
+ }
898
+ async deleteAllForUser(userId) {
899
+ let removed = 0;
900
+ for (const [key, row] of this.store) if (row.userId === userId) {
901
+ this.store.delete(key);
902
+ removed++;
903
+ }
904
+ return removed;
905
+ }
906
+ };
907
+ /** Null-byte separator can't appear in a provider id / IdP subject, so the join is unambiguous. */
908
+ function compositeKey(provider, subject) {
909
+ return `${provider}${subject}`;
910
+ }
911
+ //#endregion
859
912
  //#region src/password/policies.ts
860
913
  const ppHasMinLength = (min = 8) => definePasswordPolicy({
861
914
  rule: (v, min) => v.length >= min,
@@ -894,4 +947,30 @@ const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
894
947
  errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
895
948
  });
896
949
  //#endregion
897
- 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 };
950
+ //#region src/mfa/codes.ts
951
+ /**
952
+ * SHA-256 hash of an MFA code (e.g. one-time email/SMS code or backup code).
953
+ *
954
+ * Hex-encoded for stable, comparable output regardless of input case/format.
955
+ * Use {@link verifyMfaCode} to compare a submitted plaintext code against the
956
+ * stored hash in constant time.
957
+ */
958
+ function hashMfaCode(code) {
959
+ return createHash("sha256").update(code).digest("hex");
960
+ }
961
+ /**
962
+ * Constant-time comparison of a submitted plaintext code against an
963
+ * expected SHA-256 hex hash (as produced by {@link hashMfaCode}).
964
+ *
965
+ * Returns false for malformed/empty expected hashes (timingSafeEqual
966
+ * requires equal-length, non-empty buffers).
967
+ */
968
+ function verifyMfaCode(submitted, expectedHash) {
969
+ if (!expectedHash) return false;
970
+ const a = Buffer.from(hashMfaCode(submitted), "hex");
971
+ const b = Buffer.from(expectedHash, "hex");
972
+ if (a.length !== b.length) return false;
973
+ return timingSafeEqual(a, b);
974
+ }
975
+ //#endregion
976
+ 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 };