@nxgt/janus 0.12.0 → 0.14.0

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.
Files changed (73) hide show
  1. package/README.md +62 -13
  2. package/dist/auth/config/index.d.ts +1 -1
  3. package/dist/auth/config/index.d.ts.map +1 -1
  4. package/dist/auth/config/janus-config.d.ts +23 -0
  5. package/dist/auth/config/janus-config.d.ts.map +1 -1
  6. package/dist/auth/config/resolve-config.d.ts.map +1 -1
  7. package/dist/auth/config/resolve-sign-in.d.ts +5 -0
  8. package/dist/auth/config/resolve-sign-in.d.ts.map +1 -0
  9. package/dist/auth/config/resolved-config.d.ts +5 -0
  10. package/dist/auth/config/resolved-config.d.ts.map +1 -1
  11. package/dist/auth/derived-keys.d.ts +9 -0
  12. package/dist/auth/derived-keys.d.ts.map +1 -0
  13. package/dist/auth/email-flows/reset-password.d.ts.map +1 -1
  14. package/dist/auth/events.d.ts +19 -4
  15. package/dist/auth/events.d.ts.map +1 -1
  16. package/dist/auth/index.d.ts +1 -1
  17. package/dist/auth/index.d.ts.map +1 -1
  18. package/dist/auth/one-time/index.d.ts +3 -1
  19. package/dist/auth/one-time/index.d.ts.map +1 -1
  20. package/dist/auth/one-time/window.d.ts +50 -0
  21. package/dist/auth/one-time/window.d.ts.map +1 -0
  22. package/dist/auth/password-written.d.ts +28 -5
  23. package/dist/auth/password-written.d.ts.map +1 -1
  24. package/dist/auth/port/types/tokens.d.ts +3 -2
  25. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  26. package/dist/auth/second-factor/app-code-attempts.d.ts +5 -8
  27. package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -1
  28. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  29. package/dist/auth/second-factor/recovery-codes.d.ts.map +1 -1
  30. package/dist/auth/second-factor/recovery.d.ts.map +1 -1
  31. package/dist/auth/sign-in-attempts.d.ts +43 -0
  32. package/dist/auth/sign-in-attempts.d.ts.map +1 -0
  33. package/dist/auth/types/email-flows.d.ts +6 -3
  34. package/dist/auth/types/email-flows.d.ts.map +1 -1
  35. package/dist/auth/types/password.d.ts +8 -0
  36. package/dist/auth/types/password.d.ts.map +1 -1
  37. package/dist/auth/users/fields-patch.d.ts +8 -2
  38. package/dist/auth/users/fields-patch.d.ts.map +1 -1
  39. package/dist/auth/users/record-flows.d.ts.map +1 -1
  40. package/dist/auth/users/sign-in.d.ts +2 -2
  41. package/dist/auth/users/sign-in.d.ts.map +1 -1
  42. package/dist/auth/users/update.d.ts +12 -0
  43. package/dist/auth/users/update.d.ts.map +1 -0
  44. package/dist/chunks/{index-km7ffctx.js → index-6c9fbtv2.js} +2 -2
  45. package/dist/chunks/{index-rd2rddvv.js → index-baazjyvc.js} +2 -2
  46. package/dist/chunks/{index-j2gd8fry.js → index-hnwggyxs.js} +3 -2
  47. package/dist/chunks/{index-j2gd8fry.js.map → index-hnwggyxs.js.map} +3 -3
  48. package/dist/chunks/{index-jp2kka6d.js → index-kj3st57z.js} +2 -2
  49. package/dist/conformance/index.js +3 -3
  50. package/dist/errors/janus-error/base.d.ts +1 -0
  51. package/dist/errors/janus-error/base.d.ts.map +1 -1
  52. package/dist/errors/janus-error/codes.d.ts +3 -1
  53. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  54. package/dist/errors/janus-error/options.d.ts +9 -2
  55. package/dist/errors/janus-error/options.d.ts.map +1 -1
  56. package/dist/index.js +234 -89
  57. package/dist/index.js.map +21 -16
  58. package/dist/permissions/index.js +3 -3
  59. package/docs/README.md +2 -2
  60. package/docs/guide/adapters.md +3 -2
  61. package/docs/guide/email-flows.md +40 -1
  62. package/docs/guide/errors.md +10 -2
  63. package/docs/guide/events.md +74 -13
  64. package/docs/guide/passwords.md +123 -3
  65. package/docs/guide/second-factor.md +4 -2
  66. package/docs/guide/users.md +16 -7
  67. package/docs/guide/vocabulary.md +2 -2
  68. package/docs/roadmap.md +34 -26
  69. package/docs/troubleshooting.md +66 -3
  70. package/package.json +1 -1
  71. /package/dist/chunks/{index-km7ffctx.js.map → index-6c9fbtv2.js.map} +0 -0
  72. /package/dist/chunks/{index-rd2rddvv.js.map → index-baazjyvc.js.map} +0 -0
  73. /package/dist/chunks/{index-jp2kka6d.js.map → index-kj3st57z.js.map} +0 -0
package/dist/index.js CHANGED
@@ -7,13 +7,13 @@ import {
7
7
  MAX_PAGE_SIZE2,
8
8
  pageLimit2,
9
9
  invalidCursor2
10
- } from "./chunks/index-jp2kka6d.js";
10
+ } from "./chunks/index-kj3st57z.js";
11
11
  import {
12
12
  mintId2,
13
13
  isId2,
14
14
  mintedAt2,
15
15
  createMemoryStores2
16
- } from "./chunks/index-rd2rddvv.js";
16
+ } from "./chunks/index-baazjyvc.js";
17
17
  import {
18
18
  JanusError2,
19
19
  UserInvalidError2,
@@ -38,7 +38,7 @@ import {
38
38
  formatTuple2,
39
39
  parseTuple2,
40
40
  parseSubject2
41
- } from "./chunks/index-j2gd8fry.js";
41
+ } from "./chunks/index-hnwggyxs.js";
42
42
 
43
43
  // src/auth/hashers.ts
44
44
  import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
@@ -296,6 +296,28 @@ function resolveSecondFactor(config, where) {
296
296
  };
297
297
  }
298
298
 
299
+ // src/auth/config/resolve-sign-in.ts
300
+ function resolveSignInThrottle(config, where) {
301
+ if (config !== undefined && (typeof config !== "object" || config === null)) {
302
+ throw new TypeError(`${where}: signIn must be an object — { throttle }`);
303
+ }
304
+ const throttle = config?.throttle ?? {};
305
+ if (throttle === false)
306
+ return null;
307
+ const at = `${where}: signIn.throttle`;
308
+ if (typeof throttle !== "object" || throttle === null) {
309
+ throw new TypeError(`${at} must be { attempts, window }, or false to count nothing`);
310
+ }
311
+ const attempts = throttle.attempts ?? 10;
312
+ if (!Number.isSafeInteger(attempts) || attempts < 1) {
313
+ throw new TypeError(`${at}.attempts must be a whole number above zero`);
314
+ }
315
+ return {
316
+ attempts,
317
+ windowMs: parseDuration(throttle.window ?? "15m", `${at}.window`)
318
+ };
319
+ }
320
+
299
321
  // src/auth/config/resolve-type.ts
300
322
  function resolveType(name, type, at, schemaKey) {
301
323
  if (typeof type?.schema !== "object" || type.schema === null || typeof type.schema["~standard"]?.validate !== "function") {
@@ -342,6 +364,7 @@ function resolveConfig(config, where) {
342
364
  types,
343
365
  tokenTtlMs: resolveTokenTtls(config, where),
344
366
  cookie: resolveCookie(config.cookie ?? {}, where),
367
+ signInThrottle: resolveSignInThrottle(config.signIn, where),
345
368
  secondFactor: resolveSecondFactor(config.secondFactor, where)
346
369
  };
347
370
  }
@@ -632,7 +655,7 @@ function resolveEvents(events, where) {
632
655
  }
633
656
  return events;
634
657
  }
635
- async function emit(context, type, user, occurredAt) {
658
+ async function emit(context, type, user, occurredAt, extras = {}) {
636
659
  const listener = context.events;
637
660
  if (listener === null)
638
661
  return;
@@ -641,7 +664,8 @@ async function emit(context, type, user, occurredAt) {
641
664
  type,
642
665
  occurredAt: new Date(occurredAt.getTime()),
643
666
  userId: user.id,
644
- userType: user.type
667
+ userType: user.type,
668
+ ...extras
645
669
  });
646
670
  try {
647
671
  await listener(event);
@@ -977,9 +1001,87 @@ async function insertOneTime(context, token, code) {
977
1001
  });
978
1002
  return { secret, expiresAt };
979
1003
  }
1004
+ // src/auth/one-time/window.ts
1005
+ var MAX_LINKS = 32;
1006
+ var MAX_PROBES = 64;
1007
+ async function countInWindow(context, counting) {
1008
+ const now = context.clock.now().getTime();
1009
+ const window = Math.floor(now / counting.windowMs);
1010
+ const expiresAt = (window + 2) * counting.windowMs;
1011
+ const links = { context, counting, window, now, expiresAt };
1012
+ const counted = counting.spent === "carry" ? await countCarrying(links) : await countRestarting(links);
1013
+ return { ...counted, endsAt: (window + 1) * counting.windowMs };
1014
+ }
1015
+ async function countCarrying(links) {
1016
+ let carried = 0;
1017
+ for (let link = 0;link < MAX_LINKS; link += 1) {
1018
+ const hash = links.counting.linkHash(links.window, link);
1019
+ const counted = await countLink(links, hash);
1020
+ const attempts = carried + counted.attempts;
1021
+ if (counted.spentAt === null)
1022
+ return { attempts, link: hash };
1023
+ carried = attempts;
1024
+ }
1025
+ return { attempts: Number.POSITIVE_INFINITY, link: null };
1026
+ }
1027
+ async function countRestarting(links) {
1028
+ const tokens = links.context.store.tokens;
1029
+ let spent = -1;
1030
+ let absent = Number.POSITIVE_INFINITY;
1031
+ for (let probes = 0;probes < MAX_PROBES; probes += 1) {
1032
+ const next = absent === spent + 1;
1033
+ const link = next ? absent : absent === Number.POSITIVE_INFINITY ? 2 * spent + 2 : Math.floor((spent + absent) / 2);
1034
+ const hash = links.counting.linkHash(links.window, link);
1035
+ const counted = next ? await insertLink(links, hash) : await tokens.countAttempt(hash, "secondFactor");
1036
+ if (counted === null)
1037
+ absent = link;
1038
+ else if (counted.spentAt === null) {
1039
+ return { attempts: counted.attempts, link: hash };
1040
+ } else {
1041
+ spent = link;
1042
+ if (absent <= spent)
1043
+ absent = Number.POSITIVE_INFINITY;
1044
+ }
1045
+ }
1046
+ throw links.counting.vanished();
1047
+ }
1048
+ async function countLink(links, tokenHash) {
1049
+ const tokens = links.context.store.tokens;
1050
+ const counted = await tokens.countAttempt(tokenHash, "secondFactor");
1051
+ return counted ?? insertLink(links, tokenHash);
1052
+ }
1053
+ async function insertLink(links, tokenHash) {
1054
+ const tokens = links.context.store.tokens;
1055
+ await tokens.insertToken({
1056
+ tokenHash,
1057
+ kind: "secondFactor",
1058
+ userId: links.counting.userId,
1059
+ address: "",
1060
+ codeHash: null,
1061
+ attempts: 0,
1062
+ expiresAt: new Date(links.expiresAt),
1063
+ spentAt: null,
1064
+ createdAt: new Date(links.now)
1065
+ });
1066
+ const inserted = await tokens.countAttempt(tokenHash, "secondFactor");
1067
+ if (inserted !== null)
1068
+ return inserted;
1069
+ throw links.counting.vanished();
1070
+ }
980
1071
  // src/auth/password-written.ts
981
- async function endSignInsWaiting(context, userId) {
982
- await context.store.tokens.spendUserTokens(userId, "secondFactor", context.clock.now());
1072
+ async function endWhatThePasswordOpened(context, userId) {
1073
+ const now = context.clock.now();
1074
+ const spent = await Promise.allSettled(["resetPassword", "secondFactor"].map((kind) => context.store.tokens.spendUserTokens(userId, kind, now)));
1075
+ const failed = spent.find((result) => result.status === "rejected");
1076
+ if (failed !== undefined)
1077
+ throw failed.reason;
1078
+ }
1079
+ async function passwordChanged(context, written) {
1080
+ try {
1081
+ await endWhatThePasswordOpened(context, written.id);
1082
+ } finally {
1083
+ await emit(context, "user.passwordChanged", written, written.updatedAt);
1084
+ }
983
1085
  }
984
1086
  async function heldByPassword(context, type, verified, password, result, where) {
985
1087
  const now = await findRecord(context, verified.id, type.name);
@@ -1031,6 +1133,7 @@ function resetPasswordFlows(context, type, at) {
1031
1133
  if (record === null)
1032
1134
  return null;
1033
1135
  const issued = await issueEmailToken(context, type, "resetPassword", record, where);
1136
+ await context.store.tokens.spendUserTokens(record.id, "resetPassword", context.clock.now(), hashSecret(issued.token));
1034
1137
  return { ...issued, user: toUser(record) };
1035
1138
  },
1036
1139
  async confirm(secret, password) {
@@ -1054,7 +1157,7 @@ async function confirmReset(context, type, secret, password, where) {
1054
1157
  });
1055
1158
  try {
1056
1159
  await context.store.sessions.revokeUserSessions(written.id, context.clock.now());
1057
- await endSignInsWaiting(context, written.id);
1160
+ await endWhatThePasswordOpened(context, written.id);
1058
1161
  } finally {
1059
1162
  await emit(context, "user.passwordReset", written, written.updatedAt);
1060
1163
  if (newlyVerified) {
@@ -1096,8 +1199,78 @@ function emailFlows(context, type, at) {
1096
1199
  resetPassword: resetPasswordFlows(context, type, at)
1097
1200
  };
1098
1201
  }
1202
+ // src/auth/derived-keys.ts
1203
+ import { createHmac, hkdfSync } from "node:crypto";
1204
+ var derived = new WeakMap;
1205
+ function derivedKey(sealer, purpose, keyId = sealer.sealWith) {
1206
+ let keys = derived.get(sealer);
1207
+ if (keys === undefined) {
1208
+ keys = new Map;
1209
+ derived.set(sealer, keys);
1210
+ }
1211
+ const name = `${purpose}\x00${keyId}`;
1212
+ let key = keys.get(name);
1213
+ if (key === undefined) {
1214
+ const secret = sealer.keys.get(keyId);
1215
+ if (secret === undefined)
1216
+ return;
1217
+ key = Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), purpose, 32));
1218
+ keys.set(name, key);
1219
+ }
1220
+ return key;
1221
+ }
1222
+ function keyedHash(key, parts) {
1223
+ return createHmac("sha256", key).update(parts.join("\x00")).digest();
1224
+ }
1225
+
1226
+ // src/auth/sign-in-attempts.ts
1227
+ var PURPOSE = "janus/sign-in/attempts/v1";
1228
+ var UNSEALED = Buffer.from(PURPOSE);
1229
+ function counterKey(context) {
1230
+ const sealer = context.config.secondFactor?.sealer;
1231
+ return sealer === undefined ? UNSEALED : derivedKey(sealer, PURPOSE);
1232
+ }
1233
+ var keyed = (key, parts) => keyedHash(key, parts).toString("base64url");
1234
+ async function countLogin(context, type, login, where) {
1235
+ const throttle = context.config.signInThrottle;
1236
+ if (throttle === null)
1237
+ return null;
1238
+ const key = counterKey(context);
1239
+ const loginKey = keyed(key, [type.name, login]);
1240
+ return countInWindow(context, {
1241
+ windowMs: throttle.windowMs,
1242
+ spent: "restart",
1243
+ userId: loginKey,
1244
+ linkHash: (window, link) => hashSecret(keyed(key, [loginKey, String(window), String(link)])),
1245
+ vanished: () => new StoreFailure2(`${where}: the store dropped the attempts it had just stored`, { slot: "tokens", operation: "countAttempt" })
1246
+ });
1247
+ }
1248
+ async function countSignInAttempt(context, type, login, where) {
1249
+ const counted = await countLogin(context, type, login, where);
1250
+ const limit = context.config.signInThrottle?.attempts;
1251
+ if (counted === null || limit === undefined || counted.attempts <= limit) {
1252
+ return;
1253
+ }
1254
+ const now = context.clock.now().getTime();
1255
+ throw new CredentialError2("CREDENTIALS_INVALID", `${where}: too many passwords tried at this login — wait for the next window`, {
1256
+ reason: "throttled",
1257
+ userType: type.name,
1258
+ retryAfter: Math.max(1, Math.ceil((counted.endsAt - now) / 1000))
1259
+ });
1260
+ }
1261
+ async function restartSignInCount(context, type, record, where) {
1262
+ const rule = type.password;
1263
+ const login = rule === null ? undefined : record.fields[rule.login];
1264
+ if (rule === null || typeof login !== "string")
1265
+ return;
1266
+ const counted = await countLogin(context, type, rule.normalize(login), where);
1267
+ if (counted?.link == null)
1268
+ return;
1269
+ await context.store.tokens.consumeToken(counted.link, "secondFactor", context.clock.now());
1270
+ }
1271
+
1099
1272
  // src/auth/totp.ts
1100
- import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
1273
+ import { createHmac as createHmac2, randomBytes as randomBytes4 } from "node:crypto";
1101
1274
  var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
1102
1275
  var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
1103
1276
  function mintTotpSecret() {
@@ -1140,7 +1313,7 @@ var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
1140
1313
  function codeAt(secret, step) {
1141
1314
  const counter = Buffer.alloc(8);
1142
1315
  counter.writeBigUInt64BE(BigInt(step));
1143
- const digest = createHmac("sha1", secret).update(counter).digest();
1316
+ const digest = createHmac2("sha1", secret).update(counter).digest();
1144
1317
  const offset = digest[19] & 15;
1145
1318
  const binary = digest.readUInt32BE(offset) & 2147483647;
1146
1319
  return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
@@ -1267,6 +1440,7 @@ async function confirmChallenge(context, type, challenge, code, at) {
1267
1440
  return refuseCode(context, type, opened, where);
1268
1441
  const written = await context.store.users.updateUser(record.id, { secondFactor: accepted, updatedAt: now }, record.version);
1269
1442
  await spendChallenge(context, opened.secret, where);
1443
+ await restartSignInCount(context, type, written, where);
1270
1444
  return openSession(context, type, written);
1271
1445
  }
1272
1446
 
@@ -1278,12 +1452,12 @@ async function recoveryCodesLeftOf(context, type, user, where) {
1278
1452
  }
1279
1453
 
1280
1454
  // src/auth/second-factor/recovery-codes.ts
1281
- import { createHmac as createHmac2, hkdfSync, randomInt as randomInt2, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
1455
+ import { randomInt as randomInt2, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
1282
1456
  var RECOVERY_CODES = 10;
1283
1457
  var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
1284
1458
  var LENGTH = 10;
1285
1459
  var SHAPE = /^[0-9a-hjkmnp-tv-z]{10}$/;
1286
- var PURPOSE = "janus/second-factor/recovery-codes/v1";
1460
+ var PURPOSE2 = "janus/second-factor/recovery-codes/v1";
1287
1461
  function mintRecoveryCodes() {
1288
1462
  return Array.from({ length: RECOVERY_CODES }, () => {
1289
1463
  let code = "";
@@ -1297,24 +1471,8 @@ function readRecoveryCode(typed) {
1297
1471
  const code = typed.toLowerCase().replace(/[\s-]/g, "").replace(/o/g, "0").replace(/[il]/g, "1");
1298
1472
  return SHAPE.test(code) ? code : null;
1299
1473
  }
1300
- var derived = new WeakMap;
1301
- function macKey(sealer, keyId) {
1302
- let keys = derived.get(sealer);
1303
- if (keys === undefined) {
1304
- keys = new Map;
1305
- derived.set(sealer, keys);
1306
- }
1307
- let key = keys.get(keyId);
1308
- if (key === undefined) {
1309
- const secret = sealer.keys.get(keyId);
1310
- if (secret === undefined)
1311
- return;
1312
- key = Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), PURPOSE, 32));
1313
- keys.set(keyId, key);
1314
- }
1315
- return key;
1316
- }
1317
- var mac = (key, userId, code) => createHmac2("sha256", key).update(`${userId}\x00${code}`).digest();
1474
+ var macKey = (sealer, keyId) => derivedKey(sealer, PURPOSE2, keyId);
1475
+ var mac = (key, userId, code) => keyedHash(key, [userId, code]);
1318
1476
  function hashRecoveryCode(sealer, userId, code) {
1319
1477
  const read = readRecoveryCode(code) ?? code;
1320
1478
  const key = macKey(sealer, sealer.sealWith);
@@ -1422,21 +1580,9 @@ function accountOf(type, record, where) {
1422
1580
  }
1423
1581
 
1424
1582
  // src/auth/second-factor/app-code-attempts.ts
1425
- import { createHmac as createHmac3, hkdfSync as hkdfSync2 } from "node:crypto";
1426
1583
  var APP_CODE_WINDOW_MS = 15 * 60000;
1427
1584
  var WINDOW_MINUTES = APP_CODE_WINDOW_MS / 60000;
1428
- var PURPOSE2 = "janus/second-factor/regenerate-attempts/v1";
1429
- var MAX_LINKS = 32;
1430
- var derived2 = new WeakMap;
1431
- function counterKey(sealer) {
1432
- let key = derived2.get(sealer);
1433
- if (key === undefined) {
1434
- const secret = sealer.keys.get(sealer.sealWith);
1435
- key = Buffer.from(hkdfSync2("sha256", secret, Buffer.alloc(0), PURPOSE2, 32));
1436
- derived2.set(sealer, key);
1437
- }
1438
- return key;
1439
- }
1585
+ var PURPOSE3 = "janus/second-factor/regenerate-attempts/v1";
1440
1586
  function linkHash(sealer, record, window, link) {
1441
1587
  const parts = [
1442
1588
  record.id,
@@ -1444,45 +1590,21 @@ function linkHash(sealer, record, window, link) {
1444
1590
  String(record.secondFactor?.lastStep ?? ""),
1445
1591
  String(link)
1446
1592
  ];
1447
- return hashSecret(createHmac3("sha256", counterKey(sealer)).update(parts.join("\x00")).digest("base64url"));
1593
+ const key = derivedKey(sealer, PURPOSE3);
1594
+ return hashSecret(keyedHash(key, parts).toString("base64url"));
1448
1595
  }
1449
1596
  async function countAppCodeAttempt(context, sealer, record, where) {
1450
- const now = context.clock.now().getTime();
1451
- const window = Math.floor(now / APP_CODE_WINDOW_MS);
1452
- const times = { now, expiresAt: (window + 2) * APP_CODE_WINDOW_MS };
1453
- let spent = 0;
1454
- for (let link = 0;link < MAX_LINKS; link += 1) {
1455
- const hash = linkHash(sealer, record, window, link);
1456
- const counted = await countLink(context, hash, record, times, where);
1457
- const attempts = spent + counted.attempts;
1458
- if (counted.spentAt === null)
1459
- return attemptsLeft(attempts, record, where);
1460
- spent = attempts;
1461
- }
1462
- return attemptsLeft(Number.POSITIVE_INFINITY, record, where);
1463
- }
1464
- async function countLink(context, tokenHash, record, times, where) {
1465
- const tokens = context.store.tokens;
1466
- const counted = await tokens.countAttempt(tokenHash, "secondFactor");
1467
- if (counted !== null)
1468
- return counted;
1469
- await tokens.insertToken({
1470
- tokenHash,
1471
- kind: "secondFactor",
1597
+ const { attempts } = await countInWindow(context, {
1598
+ windowMs: APP_CODE_WINDOW_MS,
1599
+ spent: "carry",
1472
1600
  userId: record.id,
1473
- address: "",
1474
- codeHash: null,
1475
- attempts: 0,
1476
- expiresAt: new Date(times.expiresAt),
1477
- spentAt: null,
1478
- createdAt: new Date(times.now)
1601
+ linkHash: (window, link) => linkHash(sealer, record, window, link),
1602
+ vanished: () => new StoreConflict2("version", `${where}: the user changed while the code was checked — read it again and retry`, {
1603
+ userId: record.id,
1604
+ expectedVersion: record.version,
1605
+ operation: where
1606
+ })
1479
1607
  });
1480
- const inserted = await tokens.countAttempt(tokenHash, "secondFactor");
1481
- if (inserted !== null)
1482
- return inserted;
1483
- throw new StoreConflict2("version", `${where}: the user changed while the code was checked — read it again and retry`, { userId: record.id, expectedVersion: record.version, operation: where });
1484
- }
1485
- function attemptsLeft(attempts, record, where) {
1486
1608
  if (attempts > CODE_ATTEMPTS) {
1487
1609
  throw new TokenError2("CODE_INVALID", `${where}: too many codes tried — wait for the next ${WINDOW_MINUTES}-minute window`, {
1488
1610
  operation: where,
@@ -1553,6 +1675,7 @@ async function recoverWithCode(context, type, challenge, code, at) {
1553
1675
  }, record.version);
1554
1676
  try {
1555
1677
  await spendChallenge(context, opened.secret, where);
1678
+ await restartSignInCount(context, type, written, where);
1556
1679
  const signedIn = await openSession(context, type, written);
1557
1680
  return { ...signedIn, recoveryCodesLeft: recoveryCodes.length };
1558
1681
  } finally {
@@ -1793,7 +1916,7 @@ async function changePassword(context, type, user, change, options, where) {
1793
1916
  }
1794
1917
  };
1795
1918
  });
1796
- await endSignInsWaiting(context, written.id);
1919
+ await passwordChanged(context, written);
1797
1920
  return written;
1798
1921
  }
1799
1922
 
@@ -1835,7 +1958,11 @@ async function signIn(context, type, finish, input, where) {
1835
1958
  const hasher = requireHasher(context, where);
1836
1959
  const login = input?.[rule.login];
1837
1960
  const password = input?.password;
1838
- const record = typeof login === "string" ? await byLogin(context, type, rule.normalize(login)) : null;
1961
+ const normalized = typeof login === "string" ? rule.normalize(login) : null;
1962
+ if (normalized !== null) {
1963
+ await countSignInAttempt(context, type, normalized, where);
1964
+ }
1965
+ const record = normalized === null ? null : await byLogin(context, type, normalized);
1839
1966
  const refuse = (reason) => new CredentialError2("CREDENTIALS_INVALID", `${where}: the login and the password do not match`, { reason, userType: type.name });
1840
1967
  if (record === null || record.password === null) {
1841
1968
  await hasher.verify(String(password), await context.dummyHash());
@@ -1850,6 +1977,9 @@ async function signIn(context, type, finish, input, where) {
1850
1977
  userType: type.name
1851
1978
  });
1852
1979
  }
1980
+ if (!isActive(record.secondFactor)) {
1981
+ await restartSignInCount(context, type, record, where);
1982
+ }
1853
1983
  const verified = await rehashed(context, record, String(password));
1854
1984
  const result = await finish(verified, where);
1855
1985
  if (!await heldByPassword(context, type, verified, String(password), result, where)) {
@@ -1884,7 +2014,7 @@ function passwordFlows(context, type, at, finish) {
1884
2014
  checkPassword(type, password, where);
1885
2015
  const hash = await requireHasher(context, where).hash(password);
1886
2016
  const written = await writeUser(context, user, type, options, where, (_, now) => ({ password: { hash, updatedAt: now } }));
1887
- await endSignInsWaiting(context, written.id);
2017
+ await passwordChanged(context, written);
1888
2018
  return toUser(written);
1889
2019
  },
1890
2020
  async changePassword(user, change, options) {
@@ -1918,19 +2048,36 @@ async function deleteUser(context, type, user) {
1918
2048
  }
1919
2049
 
1920
2050
  // src/auth/users/fields-patch.ts
2051
+ var normalized = (email) => email === null ? null : normalizeEmail(email);
2052
+ function emailChanged(type, before, after) {
2053
+ return normalized(emailOf(type, before)) !== normalized(emailOf(type, after));
2054
+ }
1921
2055
  async function fieldsPatch(type, record, patch, where) {
1922
2056
  const fields = await validateFields(type, { ...record.fields, ...patch }, where);
1923
- const before = emailOf(type, record.fields);
1924
- const after = emailOf(type, fields);
1925
- const emailChanged = (before === null ? null : normalizeEmail(before)) !== (after === null ? null : normalizeEmail(after));
1926
2057
  return {
1927
2058
  fields,
1928
2059
  logins: loginsOf(type, fields, where),
1929
2060
  schemaVersion: type.schemaVersion,
1930
- ...emailChanged ? { emailVerifiedAt: null } : {}
2061
+ ...emailChanged(type, record.fields, fields) ? { emailVerifiedAt: null } : {}
1931
2062
  };
1932
2063
  }
1933
2064
 
2065
+ // src/auth/users/update.ts
2066
+ async function update(context, type, user, patch, options, where) {
2067
+ const seen = { replaced: null };
2068
+ const written = await writeUser(context, user, type, options, where, (record) => {
2069
+ seen.replaced = record;
2070
+ return fieldsPatch(type, record, patch, where);
2071
+ });
2072
+ const { replaced } = seen;
2073
+ if (replaced !== null && emailChanged(type, replaced.fields, written.fields)) {
2074
+ await emit(context, "user.emailChanged", written, written.updatedAt, {
2075
+ formerEmail: emailOf(type, replaced.fields)
2076
+ });
2077
+ }
2078
+ return written;
2079
+ }
2080
+
1934
2081
  // src/auth/users/record-flows.ts
1935
2082
  function recordFlows(context, type, at) {
1936
2083
  const { store } = context;
@@ -1958,9 +2105,7 @@ function recordFlows(context, type, at) {
1958
2105
  return { items: answer.items.map(toUser), nextCursor: answer.nextCursor };
1959
2106
  },
1960
2107
  async update(user, patch, options) {
1961
- const where = at("update");
1962
- const written = await writeUser(context, user, type, options, where, (record) => fieldsPatch(type, record, patch, where));
1963
- return toUser(written);
2108
+ return toUser(await update(context, type, user, patch, options, at("update")));
1964
2109
  },
1965
2110
  async setActive(user, active, options) {
1966
2111
  return toUser(await writeUser(context, user, type, options, at("setActive"), () => ({
@@ -2070,5 +2215,5 @@ export {
2070
2215
  systemClock
2071
2216
  };
2072
2217
 
2073
- //# debugId=301D5A6A554F106664756E2164756E21
2218
+ //# debugId=7CE655305FF58FDF64756E2164756E21
2074
2219
  //# sourceMappingURL=index.js.map