@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.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);
@@ -338,7 +283,8 @@ function resolveConfig(config) {
338
283
  scryptR: config?.password?.scryptR ?? 8,
339
284
  scryptP: config?.password?.scryptP ?? 1,
340
285
  keyLength: config?.password?.keyLength ?? 64,
341
- policies: normalizePolicies(config?.password?.policies)
286
+ policies: normalizePolicies(config?.password?.policies),
287
+ maxAgeMs: config?.password?.maxAgeMs ?? 0
342
288
  },
343
289
  lockout: {
344
290
  threshold: config?.lockout?.threshold ?? 0,
@@ -359,7 +305,17 @@ function deviceTrustSafeEqual(a, b) {
359
305
  if (ab.length !== bb.length) return false;
360
306
  return (0, node_crypto.timingSafeEqual)(ab, bb);
361
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
+ */
362
317
  var UserService = class {
318
+ store;
363
319
  config;
364
320
  hasher;
365
321
  constructor(store, config) {
@@ -371,25 +327,31 @@ var UserService = class {
371
327
  * Creates a user with `account.active: false`. The invite workflow relies
372
328
  * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
373
329
  * 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
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
377
333
  * enumeration, so the failure is silent client-side.
378
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
+ *
379
339
  * @param extras Optional partial user fields merged AFTER the base
380
340
  * `UserCredentials` shape, so callers can populate consumer-specific
381
341
  * required fields (e.g. `tenantId`) without subclassing the store.
382
342
  * 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.
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.
386
346
  */
387
347
  async createUser(username, password, extras) {
388
348
  const pw = password ?? this.hasher.generatePassword();
349
+ const hash = await this.hasher.hash(pw);
389
350
  const userData = {
351
+ id: (0, node_crypto.randomUUID)(),
390
352
  username,
391
353
  password: {
392
- hash: await this.hasher.hash(pw),
354
+ hash,
393
355
  history: [],
394
356
  lastChanged: this.config.clock(),
395
357
  isInitial: !password
@@ -412,19 +374,36 @@ var UserService = class {
412
374
  await this.store.create(userData);
413
375
  return userData;
414
376
  }
415
- async getUser(username) {
416
- const user = await this.store.findByUsername(username);
417
- 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");
418
381
  return user;
419
382
  }
420
- async login(username, password) {
421
- const user = await this.store.findByUsername(username);
422
- if (!user) throw new require_user_store.UserAuthError("NOT_FOUND");
423
- if (!user.account.active) throw new require_user_store.UserAuthError("INACTIVE");
424
- 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);
425
404
  if (await this.hasher.verify(password, user.password.hash)) {
426
405
  const now = this.config.clock();
427
- await this.store.update(username, { set: { account: {
406
+ await this.store.update(user.id, { set: { account: {
428
407
  lastLogin: now,
429
408
  failedLoginAttempts: 0
430
409
  } } });
@@ -435,30 +414,30 @@ var UserService = class {
435
414
  mfaRequired: this.hasConfirmedMfaMethods(user.mfa)
436
415
  };
437
416
  }
438
- return this.incrementAndMaybeLock(username, user.account, "INVALID_CREDENTIALS");
417
+ return this.incrementAndMaybeLock(user.id, user.account, "INVALID_CREDENTIALS", lockoutOverride);
439
418
  }
440
- async verifyPassword(username, password) {
441
- const user = await this.store.findByUsername(username);
442
- 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");
443
422
  return this.hasher.verify(password, user.password.hash);
444
423
  }
445
- async changePassword(username, currentPassword, newPassword, repeatPassword) {
446
- if (repeatPassword !== void 0 && newPassword !== repeatPassword) throw new require_user_store.UserAuthError("PASSWORDS_MISMATCH");
447
- const user = await this.getUser(username);
448
- if (!await this.hasher.verify(currentPassword, user.password.hash)) throw new require_user_store.UserAuthError("INVALID_CREDENTIALS");
449
- 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);
450
429
  }
451
- async setPassword(username, newPassword) {
452
- const user = await this.getUser(username);
453
- 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);
454
433
  }
455
434
  /**
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.
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.
459
438
  */
460
- async deleteUser(username) {
461
- 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");
462
441
  }
463
442
  /**
464
443
  * Deep-merge `patch` into the user record (top-level fields are shallow-
@@ -466,31 +445,31 @@ var UserService = class {
466
445
  * `@db.patch.strategy 'merge'` declaration). Returns the patched record.
467
446
  * Used by the invite workflow's `applyProfile` default fallback.
468
447
  */
469
- async update(username, patch) {
470
- if (!await this.store.update(username, { set: patch })) throw new require_user_store.UserAuthError("NOT_FOUND");
471
- 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);
472
451
  }
473
- async activateAccount(username) {
474
- 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");
475
454
  }
476
- async deactivateAccount(username) {
477
- 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");
478
457
  }
479
- async lockAccount(username, reason, duration) {
458
+ async lockAccount(id, reason, duration) {
480
459
  const lockEnds = duration ? this.config.clock() + duration : 0;
481
- if (!await this.store.update(username, { set: { account: {
460
+ if (!await this.store.update(id, { set: { account: {
482
461
  locked: true,
483
462
  lockReason: reason,
484
463
  lockEnds
485
- } } })) throw new require_user_store.UserAuthError("NOT_FOUND");
464
+ } } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
486
465
  }
487
- async unlockAccount(username) {
488
- if (!await this.store.update(username, { set: { account: {
466
+ async unlockAccount(id) {
467
+ if (!await this.store.update(id, { set: { account: {
489
468
  locked: false,
490
469
  lockReason: "",
491
470
  lockEnds: 0,
492
471
  failedLoginAttempts: 0
493
- } } })) throw new require_user_store.UserAuthError("NOT_FOUND");
472
+ } } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
494
473
  }
495
474
  getLockStatus(account) {
496
475
  if (!account.locked) return {
@@ -506,6 +485,24 @@ var UserService = class {
506
485
  lockEnds: account.lockEnds
507
486
  };
508
487
  }
488
+ /**
489
+ * Returns `true` when the user's password is older than
490
+ * `config.password.maxAgeMs`. Returns `false` when:
491
+ * - `maxAgeMs` is unset or `0` (expiry disabled)
492
+ * - `password.lastChanged` is `0` / falsy (no recorded change — never
493
+ * expire a user whose timestamp wasn't captured, since that would
494
+ * force-loop on every login)
495
+ *
496
+ * Consulted by `@aooth/auth-moost` `LoginWorkflow`'s `credentials`
497
+ * step to set `ctx.isPasswordExpired` when `guards.passwordExpiry`
498
+ * is true (the default).
499
+ */
500
+ isPasswordExpired(user, now = this.config.clock()) {
501
+ const maxAgeMs = this.config.password.maxAgeMs;
502
+ const lastChanged = user.password.lastChanged;
503
+ if (!maxAgeMs || !lastChanged) return false;
504
+ return now - lastChanged > maxAgeMs;
505
+ }
509
506
  async checkPolicies(password, passwordData) {
510
507
  const result = {
511
508
  passed: true,
@@ -533,13 +530,13 @@ var UserService = class {
533
530
  errorMessage: p.errorMessage
534
531
  }));
535
532
  }
536
- async addMfaMethod(username, method) {
537
- await this.store.withCas(username, (user) => {
533
+ async addMfaMethod(id, method) {
534
+ await this.store.withCas(id, (user) => {
538
535
  return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
539
536
  });
540
537
  }
541
- async confirmMfaMethod(username, name) {
542
- await this.store.withCas(username, (user) => {
538
+ async confirmMfaMethod(id, name) {
539
+ await this.store.withCas(id, (user) => {
543
540
  let found = false;
544
541
  const methods = user.mfa.methods.map((m) => {
545
542
  if (m.name === name) {
@@ -551,90 +548,51 @@ var UserService = class {
551
548
  }
552
549
  return m;
553
550
  });
554
- if (!found) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
551
+ if (!found) throw new require_federated_identity_store.UserAuthError("MFA_NOT_CONFIGURED");
555
552
  return { set: { mfa: { methods } } };
556
553
  });
557
554
  }
558
- async removeMfaMethod(username, name) {
559
- const user = await this.getUser(username);
555
+ async removeMfaMethod(id, name) {
556
+ const user = await this.getUser(id);
560
557
  const update = { mfa: { methods: user.mfa.methods.filter((m) => m.name !== name) } };
561
558
  if (user.mfa.defaultMethod === name) update.mfa.defaultMethod = "";
562
- await this.store.update(username, { set: update });
559
+ await this.store.update(id, { set: update });
563
560
  }
564
- async setDefaultMfaMethod(username, name) {
565
- const user = await this.getUser(username);
566
- if (name && !user.mfa.methods.some((m) => m.name === name)) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
567
- 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: {
568
565
  defaultMethod: name,
569
566
  autoSend: false
570
567
  } } });
571
568
  }
572
- async setMfaAutoSend(username, value) {
573
- 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");
574
571
  }
575
572
  getAvailableMfaMethods(mfa) {
576
573
  return mfa.methods.filter((m) => m.confirmed).map((m) => ({
577
574
  name: m.name,
578
575
  isDefault: mfa.defaultMethod === m.name,
579
- masked: require_user_store.maskMfaValue(m)
576
+ masked: require_federated_identity_store.maskMfaValue(m)
580
577
  }));
581
578
  }
582
579
  /**
583
- * Generate `count` plaintext backup codes (default 10), persist their
584
- * hashes (replacing any existing batch), and return the plaintext codes
585
- * once for the caller to deliver to the user. Plaintext is never
586
- * recoverable after this call returns.
587
- *
588
- * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
589
- */
590
- async generateBackupCodes(username, count = 10) {
591
- const codes = generateBackupCodePlaintext(count);
592
- const hashes = codes.map(hashMfaCode);
593
- if (!await this.store.update(username, { set: { backupCodes: hashes } })) throw new require_user_store.UserAuthError("NOT_FOUND");
594
- return codes;
595
- }
596
- /**
597
- * Consume a backup code: returns `true` and removes the matching hash
598
- * from storage if `code` matches a stored backup code; returns `false`
599
- * if no match (without modifying storage). Single-use is enforced by
600
- * optimistic-concurrency CAS on the version column — concurrent consumes
601
- * of the same code race fairly and only one wins; the loser re-reads,
602
- * finds the hash already removed, and returns `false`.
603
- *
604
- * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
605
- * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
606
- */
607
- async consumeBackupCode(username, code) {
608
- let consumed = false;
609
- await this.store.withCas(username, (user) => {
610
- const hashes = user.backupCodes ?? [];
611
- const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
612
- if (idx < 0) {
613
- consumed = false;
614
- return null;
615
- }
616
- consumed = true;
617
- return { set: { backupCodes: hashes.filter((_, i) => i !== idx) } };
618
- });
619
- return consumed;
620
- }
621
- /**
622
580
  * Verify a TOTP code against the user's confirmed `totp` MFA method.
623
581
  * Failures bump the same `failedLoginAttempts` counter as `login` so an
624
582
  * attacker who knows the password but not the TOTP gets `lockout.threshold`
625
583
  * total tries across BOTH factors, not `2 * threshold`.
626
584
  */
627
- async verifyMfa(username, code, config) {
628
- const user = await this.getUser(username);
629
- if (!user.account.active) throw new require_user_store.UserAuthError("INACTIVE");
630
- 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);
631
589
  const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
632
- if (!totp) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
590
+ if (!totp) throw new require_federated_identity_store.UserAuthError("MFA_NOT_CONFIGURED");
633
591
  const matchedCounter = verifyTotpCode(totp.value, code, config);
634
592
  const isReplay = matchedCounter !== null && totp.lastUsedWindow !== void 0 && matchedCounter <= totp.lastUsedWindow;
635
593
  if (matchedCounter !== null && !isReplay) {
636
594
  let replayDuringCas = false;
637
- await this.store.withCas(username, (current) => {
595
+ await this.store.withCas(id, (current) => {
638
596
  const currentTotp = current.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
639
597
  if (currentTotp?.lastUsedWindow !== void 0 && matchedCounter <= currentTotp.lastUsedWindow) {
640
598
  replayDuringCas = true;
@@ -649,7 +607,7 @@ var UserService = class {
649
607
  });
650
608
  if (!replayDuringCas) return;
651
609
  }
652
- await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
610
+ await this.incrementAndMaybeLock(id, user.account, "MFA_INVALID", lockoutOverride);
653
611
  }
654
612
  /**
655
613
  * Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
@@ -662,10 +620,10 @@ var UserService = class {
662
620
  * Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
663
621
  * totp method; MFA_INVALID on wrong code.
664
622
  */
665
- async verifyTotpSetupCode(username, code, config) {
666
- const totp = (await this.getUser(username)).mfa.methods.find((m) => m.name === "totp" && !m.confirmed);
667
- if (!totp) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
668
- 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");
669
627
  }
670
628
  getPasswordHasher() {
671
629
  return this.hasher;
@@ -695,8 +653,8 @@ var UserService = class {
695
653
  * write — the array shape is preserved end-to-end so DB adapters with a
696
654
  * merge strategy replace the whole array.
697
655
  */
698
- async addTrustedDevice(username, record) {
699
- await this.store.withCas(username, (user) => {
656
+ async addTrustedDevice(id, record) {
657
+ await this.store.withCas(id, (user) => {
700
658
  return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
701
659
  });
702
660
  }
@@ -705,13 +663,13 @@ var UserService = class {
705
663
  * the configured secret, AND (b) matches a persisted record that is still
706
664
  * within its expiry window and whose bound IP (if any) matches.
707
665
  */
708
- async verifyTrustedDevice(username, token, ip) {
666
+ async verifyTrustedDevice(userId, token, ip) {
709
667
  const secret = this.requireDeviceTrustSecret();
710
668
  const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
711
669
  if (sepIdx <= 0) return false;
712
670
  const raw = token.slice(0, sepIdx);
713
- if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${username}|${raw}|${ip ?? ""}`))) return false;
714
- 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);
715
673
  if (!user) return false;
716
674
  const list = user.trustedDevices ?? [];
717
675
  const now = this.config.clock();
@@ -724,33 +682,33 @@ var UserService = class {
724
682
  * Remove a specific trust record from the user. No-op when the record is
725
683
  * absent — mirrors the legacy `DeviceTrustStore.revoke` semantics.
726
684
  */
727
- async revokeTrustedDevice(username, token) {
728
- const user = await this.store.findByUsername(username);
685
+ async revokeTrustedDevice(id, token) {
686
+ const user = await this.store.findById(id);
729
687
  if (!user) return;
730
688
  const list = user.trustedDevices ?? [];
731
689
  const next = list.filter((r) => r.token !== token);
732
690
  if (next.length === list.length) return;
733
- await this.store.update(username, { set: { trustedDevices: next } });
691
+ await this.store.update(id, { set: { trustedDevices: next } });
734
692
  }
735
- async listTrustedDevices(username) {
736
- return (await this.getUser(username)).trustedDevices ?? [];
693
+ async listTrustedDevices(id) {
694
+ return (await this.getUser(id)).trustedDevices ?? [];
737
695
  }
738
696
  requireDeviceTrustSecret() {
739
697
  const secret = this.config.deviceTrust?.secret;
740
698
  if (!secret) throw new Error("UserService: deviceTrust.secret is required to use trusted-device APIs");
741
699
  return secret;
742
700
  }
743
- async applyPasswordChange(username, user, newPassword) {
701
+ async applyPasswordChange(id, user, newPassword) {
744
702
  const policyResult = await this.checkPolicies(newPassword, user.password);
745
- 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 });
746
704
  const hashesToCheck = [user.password.hash, ...user.password.history].filter(Boolean);
747
705
  if (hashesToCheck.length > 0) {
748
- 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");
749
707
  }
750
708
  const newHash = await this.hasher.hash(newPassword);
751
709
  const limit = this.config.password.historyLength;
752
710
  const newHistory = limit > 0 ? [user.password.hash, ...user.password.history].filter(Boolean).slice(0, limit) : [];
753
- await this.store.update(username, { set: { password: {
711
+ await this.store.update(id, { set: { password: {
754
712
  hash: newHash,
755
713
  history: newHistory,
756
714
  lastChanged: this.config.clock(),
@@ -764,11 +722,11 @@ var UserService = class {
764
722
  * If `account.locked`: auto-unlock when the lock has expired (mutating
765
723
  * `account` in place), or throw `LOCKED` otherwise.
766
724
  */
767
- async ensureNotLockedOrThrow(username, account) {
725
+ async ensureNotLockedOrThrow(id, account) {
768
726
  const lockStatus = this.getLockStatus(account);
769
727
  if (!lockStatus.locked) return;
770
728
  if (lockStatus.expired) {
771
- await this.store.update(username, { set: { account: {
729
+ await this.store.update(id, { set: { account: {
772
730
  locked: false,
773
731
  lockReason: "",
774
732
  lockEnds: 0
@@ -778,7 +736,7 @@ var UserService = class {
778
736
  account.lockReason = "";
779
737
  return;
780
738
  }
781
- throw new require_user_store.UserAuthError("LOCKED", void 0, {
739
+ throw new require_federated_identity_store.UserAuthError("LOCKED", void 0, {
782
740
  reason: account.lockReason,
783
741
  lockEnds: account.lockEnds
784
742
  });
@@ -788,13 +746,21 @@ var UserService = class {
788
746
  * and always throw `errorCode` (with `details.lockEnds` when the lockout
789
747
  * just tripped). Used by both `login` and `verifyMfa` so the two factors
790
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`.
791
754
  */
792
- async incrementAndMaybeLock(username, account, errorCode) {
755
+ async incrementAndMaybeLock(id, account, errorCode, lockoutOverride) {
793
756
  const newAttempts = account.failedLoginAttempts + 1;
794
- const { threshold, duration } = this.config.lockout;
757
+ const { threshold, duration } = {
758
+ ...this.config.lockout,
759
+ ...lockoutOverride
760
+ };
795
761
  if (threshold > 0 && newAttempts >= threshold) {
796
762
  const lockEnds = duration ? this.config.clock() + duration : 0;
797
- await this.store.update(username, {
763
+ await this.store.update(id, {
798
764
  inc: { "account.failedLoginAttempts": 1 },
799
765
  set: { account: {
800
766
  locked: true,
@@ -802,61 +768,148 @@ var UserService = class {
802
768
  lockEnds
803
769
  } }
804
770
  });
805
- throw new require_user_store.UserAuthError(errorCode, void 0, { lockEnds });
771
+ throw new require_federated_identity_store.UserAuthError(errorCode, void 0, { lockEnds });
806
772
  }
807
- await this.store.update(username, { inc: { "account.failedLoginAttempts": 1 } });
808
- 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);
809
775
  }
810
776
  };
811
777
  //#endregion
812
778
  //#region src/store/memory.ts
813
- 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). */
814
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
+ */
815
787
  constructor(seed) {
816
788
  super();
817
- 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
+ }
818
794
  }
819
- async exists(username) {
820
- 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;
821
798
  }
822
- async findByUsername(username) {
823
- const user = this.store.get(username);
799
+ async findById(id) {
800
+ const user = this.store.get(id);
824
801
  return user ? structuredClone(user) : null;
825
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
+ }
826
816
  async create(data) {
827
- if (this.store.has(data.username)) throw new require_user_store.UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
828
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
+ }
829
823
  cloned.version = 0;
830
- this.store.set(data.username, cloned);
824
+ this.store.set(cloned.id, cloned);
831
825
  }
832
- async update(username, update) {
833
- const user = this.store.get(username);
826
+ async update(id, update) {
827
+ const user = this.store.get(id);
834
828
  if (!user) return false;
835
829
  if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
836
- if (update.set) require_user_store.deepMerge(user, update.set);
837
- 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);
838
832
  user.version = (user.version ?? 0) + 1;
839
833
  return true;
840
834
  }
841
- async delete(username) {
842
- return this.store.delete(username);
835
+ async delete(id) {
836
+ return this.store.delete(id);
843
837
  }
844
- async withCas(username, mutator, opts) {
838
+ async withCas(id, mutator, opts) {
845
839
  const maxAttempts = opts?.maxAttempts ?? 2;
846
840
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
847
- const current = await this.findByUsername(username);
848
- 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");
849
843
  const patch = mutator(current);
850
844
  if (patch === null) return;
851
- if (await this.update(username, {
845
+ if (await this.update(id, {
852
846
  ...patch,
853
847
  expectedVersion: current.version ?? 0
854
848
  })) return;
855
849
  }
856
- throw new require_user_store.UserAuthError("CAS_EXHAUSTED");
850
+ throw new require_federated_identity_store.UserAuthError("CAS_EXHAUSTED");
857
851
  }
858
852
  };
859
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
860
913
  //#region src/password/policies.ts
861
914
  const ppHasMinLength = (min = 8) => definePasswordPolicy({
862
915
  rule: (v, min) => v.length >= min,
@@ -895,29 +948,57 @@ const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
895
948
  errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
896
949
  });
897
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;
898
979
  exports.PasswordHasher = PasswordHasher;
899
980
  exports.PasswordPolicy = PasswordPolicy;
900
- exports.UserAuthError = require_user_store.UserAuthError;
981
+ exports.UserAuthError = require_federated_identity_store.UserAuthError;
901
982
  exports.UserService = UserService;
902
- exports.UserStore = require_user_store.UserStore;
983
+ exports.UserStore = require_federated_identity_store.UserStore;
903
984
  exports.UserStoreMemory = UserStoreMemory;
904
985
  exports.definePasswordPolicy = definePasswordPolicy;
905
- exports.generateBackupCodePlaintext = generateBackupCodePlaintext;
906
986
  exports.generateMfaCode = generateMfaCode;
907
987
  exports.generateTotpCode = generateTotpCode;
908
988
  exports.generateTotpSecret = generateTotpSecret;
909
989
  exports.generateTotpUri = generateTotpUri;
910
990
  exports.hashMfaCode = hashMfaCode;
911
- exports.maskEmail = require_user_store.maskEmail;
912
- exports.maskMfaValue = require_user_store.maskMfaValue;
913
- 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;
914
994
  exports.normalizePolicies = normalizePolicies;
995
+ exports.pickDefinedProfile = require_federated_identity_store.pickDefinedProfile;
915
996
  exports.ppHasLowerCase = ppHasLowerCase;
916
997
  exports.ppHasMinLength = ppHasMinLength;
917
998
  exports.ppHasNumber = ppHasNumber;
918
999
  exports.ppHasSpecialChar = ppHasSpecialChar;
919
1000
  exports.ppHasUpperCase = ppHasUpperCase;
920
1001
  exports.ppMaxRepeatedChars = ppMaxRepeatedChars;
921
- exports.setAtPath = require_user_store.setAtPath;
1002
+ exports.setAtPath = require_federated_identity_store.setAtPath;
922
1003
  exports.verifyMfaCode = verifyMfaCode;
923
1004
  exports.verifyTotpCode = verifyTotpCode;