@nxgt/janus 0.13.0 → 0.15.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 (92) hide show
  1. package/README.md +132 -25
  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 +25 -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 +6 -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-sign-in.d.ts +40 -0
  14. package/dist/auth/email-sign-in.d.ts.map +1 -0
  15. package/dist/auth/events.d.ts +22 -6
  16. package/dist/auth/events.d.ts.map +1 -1
  17. package/dist/auth/index.d.ts +2 -2
  18. package/dist/auth/index.d.ts.map +1 -1
  19. package/dist/auth/magic-link.d.ts +22 -0
  20. package/dist/auth/magic-link.d.ts.map +1 -0
  21. package/dist/auth/one-time/index.d.ts +3 -1
  22. package/dist/auth/one-time/index.d.ts.map +1 -1
  23. package/dist/auth/one-time/window.d.ts +50 -0
  24. package/dist/auth/one-time/window.d.ts.map +1 -0
  25. package/dist/auth/password-written.d.ts +7 -0
  26. package/dist/auth/password-written.d.ts.map +1 -1
  27. package/dist/auth/port/types/tokens.d.ts +6 -3
  28. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  29. package/dist/auth/second-factor/app-code-attempts.d.ts +5 -8
  30. package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -1
  31. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  32. package/dist/auth/second-factor/recovery-codes.d.ts.map +1 -1
  33. package/dist/auth/second-factor/recovery.d.ts.map +1 -1
  34. package/dist/auth/sign-in-attempts.d.ts +43 -0
  35. package/dist/auth/sign-in-attempts.d.ts.map +1 -0
  36. package/dist/auth/sign-in-code.d.ts +1 -3
  37. package/dist/auth/sign-in-code.d.ts.map +1 -1
  38. package/dist/auth/types/email-flows.d.ts +3 -3
  39. package/dist/auth/types/index.d.ts +1 -0
  40. package/dist/auth/types/index.d.ts.map +1 -1
  41. package/dist/auth/types/janus.d.ts +8 -1
  42. package/dist/auth/types/janus.d.ts.map +1 -1
  43. package/dist/auth/types/magic-link.d.ts +46 -0
  44. package/dist/auth/types/magic-link.d.ts.map +1 -0
  45. package/dist/auth/types/password.d.ts +8 -0
  46. package/dist/auth/types/password.d.ts.map +1 -1
  47. package/dist/auth/users/any-type-api.d.ts +2 -2
  48. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  49. package/dist/auth/users/fields-patch.d.ts +8 -2
  50. package/dist/auth/users/fields-patch.d.ts.map +1 -1
  51. package/dist/auth/users/record-flows.d.ts.map +1 -1
  52. package/dist/auth/users/sign-in.d.ts +2 -2
  53. package/dist/auth/users/sign-in.d.ts.map +1 -1
  54. package/dist/auth/users/type-api.d.ts +2 -1
  55. package/dist/auth/users/type-api.d.ts.map +1 -1
  56. package/dist/auth/users/update.d.ts +12 -0
  57. package/dist/auth/users/update.d.ts.map +1 -0
  58. package/dist/chunks/{index-km7ffctx.js → index-6c9fbtv2.js} +2 -2
  59. package/dist/chunks/{index-rd2rddvv.js → index-baazjyvc.js} +2 -2
  60. package/dist/chunks/{index-j2gd8fry.js → index-hnwggyxs.js} +3 -2
  61. package/dist/chunks/{index-j2gd8fry.js.map → index-hnwggyxs.js.map} +3 -3
  62. package/dist/chunks/{index-jp2kka6d.js → index-kj3st57z.js} +2 -2
  63. package/dist/conformance/cases/tokens/kinds.d.ts +4 -1
  64. package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -1
  65. package/dist/conformance/index.js +26 -4
  66. package/dist/conformance/index.js.map +3 -3
  67. package/dist/errors/janus-error/base.d.ts +1 -0
  68. package/dist/errors/janus-error/base.d.ts.map +1 -1
  69. package/dist/errors/janus-error/codes.d.ts +3 -1
  70. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  71. package/dist/errors/janus-error/options.d.ts +9 -2
  72. package/dist/errors/janus-error/options.d.ts.map +1 -1
  73. package/dist/index.js +296 -101
  74. package/dist/index.js.map +24 -17
  75. package/dist/permissions/index.js +3 -3
  76. package/docs/README.md +4 -3
  77. package/docs/guide/adapters.md +11 -7
  78. package/docs/guide/email-flows.md +11 -7
  79. package/docs/guide/errors.md +12 -4
  80. package/docs/guide/events.md +75 -14
  81. package/docs/guide/magic-link.md +411 -0
  82. package/docs/guide/passwords.md +116 -17
  83. package/docs/guide/second-factor.md +6 -3
  84. package/docs/guide/sign-in-code.md +14 -6
  85. package/docs/guide/users.md +13 -7
  86. package/docs/guide/vocabulary.md +6 -5
  87. package/docs/roadmap.md +37 -23
  88. package/docs/troubleshooting.md +203 -9
  89. package/package.json +1 -1
  90. /package/dist/chunks/{index-km7ffctx.js.map → index-6c9fbtv2.js.map} +0 -0
  91. /package/dist/chunks/{index-rd2rddvv.js.map → index-baazjyvc.js.map} +0 -0
  92. /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
  }
@@ -384,6 +407,7 @@ function resolveTokenTtls(config, where) {
384
407
  verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
385
408
  resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`),
386
409
  signInCode: parseDuration(config.tokens?.signInCode ?? "10m", `${where}: tokens.signInCode`),
410
+ magicLink: parseDuration(config.tokens?.magicLink ?? "15m", `${where}: tokens.magicLink`),
387
411
  stepUp: parseDuration(config.tokens?.stepUp ?? "10m", `${where}: tokens.stepUp`)
388
412
  };
389
413
  }
@@ -632,7 +656,7 @@ function resolveEvents(events, where) {
632
656
  }
633
657
  return events;
634
658
  }
635
- async function emit(context, type, user, occurredAt) {
659
+ async function emit(context, type, user, occurredAt, extras = {}) {
636
660
  const listener = context.events;
637
661
  if (listener === null)
638
662
  return;
@@ -641,7 +665,8 @@ async function emit(context, type, user, occurredAt) {
641
665
  type,
642
666
  occurredAt: new Date(occurredAt.getTime()),
643
667
  userId: user.id,
644
- userType: user.type
668
+ userType: user.type,
669
+ ...extras
645
670
  });
646
671
  try {
647
672
  await listener(event);
@@ -977,6 +1002,73 @@ async function insertOneTime(context, token, code) {
977
1002
  });
978
1003
  return { secret, expiresAt };
979
1004
  }
1005
+ // src/auth/one-time/window.ts
1006
+ var MAX_LINKS = 32;
1007
+ var MAX_PROBES = 64;
1008
+ async function countInWindow(context, counting) {
1009
+ const now = context.clock.now().getTime();
1010
+ const window = Math.floor(now / counting.windowMs);
1011
+ const expiresAt = (window + 2) * counting.windowMs;
1012
+ const links = { context, counting, window, now, expiresAt };
1013
+ const counted = counting.spent === "carry" ? await countCarrying(links) : await countRestarting(links);
1014
+ return { ...counted, endsAt: (window + 1) * counting.windowMs };
1015
+ }
1016
+ async function countCarrying(links) {
1017
+ let carried = 0;
1018
+ for (let link = 0;link < MAX_LINKS; link += 1) {
1019
+ const hash = links.counting.linkHash(links.window, link);
1020
+ const counted = await countLink(links, hash);
1021
+ const attempts = carried + counted.attempts;
1022
+ if (counted.spentAt === null)
1023
+ return { attempts, link: hash };
1024
+ carried = attempts;
1025
+ }
1026
+ return { attempts: Number.POSITIVE_INFINITY, link: null };
1027
+ }
1028
+ async function countRestarting(links) {
1029
+ const tokens = links.context.store.tokens;
1030
+ let spent = -1;
1031
+ let absent = Number.POSITIVE_INFINITY;
1032
+ for (let probes = 0;probes < MAX_PROBES; probes += 1) {
1033
+ const next = absent === spent + 1;
1034
+ const link = next ? absent : absent === Number.POSITIVE_INFINITY ? 2 * spent + 2 : Math.floor((spent + absent) / 2);
1035
+ const hash = links.counting.linkHash(links.window, link);
1036
+ const counted = next ? await insertLink(links, hash) : await tokens.countAttempt(hash, "secondFactor");
1037
+ if (counted === null)
1038
+ absent = link;
1039
+ else if (counted.spentAt === null) {
1040
+ return { attempts: counted.attempts, link: hash };
1041
+ } else {
1042
+ spent = link;
1043
+ if (absent <= spent)
1044
+ absent = Number.POSITIVE_INFINITY;
1045
+ }
1046
+ }
1047
+ throw links.counting.vanished();
1048
+ }
1049
+ async function countLink(links, tokenHash) {
1050
+ const tokens = links.context.store.tokens;
1051
+ const counted = await tokens.countAttempt(tokenHash, "secondFactor");
1052
+ return counted ?? insertLink(links, tokenHash);
1053
+ }
1054
+ async function insertLink(links, tokenHash) {
1055
+ const tokens = links.context.store.tokens;
1056
+ await tokens.insertToken({
1057
+ tokenHash,
1058
+ kind: "secondFactor",
1059
+ userId: links.counting.userId,
1060
+ address: "",
1061
+ codeHash: null,
1062
+ attempts: 0,
1063
+ expiresAt: new Date(links.expiresAt),
1064
+ spentAt: null,
1065
+ createdAt: new Date(links.now)
1066
+ });
1067
+ const inserted = await tokens.countAttempt(tokenHash, "secondFactor");
1068
+ if (inserted !== null)
1069
+ return inserted;
1070
+ throw links.counting.vanished();
1071
+ }
980
1072
  // src/auth/password-written.ts
981
1073
  async function endWhatThePasswordOpened(context, userId) {
982
1074
  const now = context.clock.now();
@@ -985,6 +1077,13 @@ async function endWhatThePasswordOpened(context, userId) {
985
1077
  if (failed !== undefined)
986
1078
  throw failed.reason;
987
1079
  }
1080
+ async function passwordChanged(context, written) {
1081
+ try {
1082
+ await endWhatThePasswordOpened(context, written.id);
1083
+ } finally {
1084
+ await emit(context, "user.passwordChanged", written, written.updatedAt);
1085
+ }
1086
+ }
988
1087
  async function heldByPassword(context, type, verified, password, result, where) {
989
1088
  const now = await findRecord(context, verified.id, type.name);
990
1089
  const kept = now !== null && now.password !== null && (now.password.hash === verified.password?.hash || await passwordMatches(context, now, password, where));
@@ -1101,8 +1200,137 @@ function emailFlows(context, type, at) {
1101
1200
  resetPassword: resetPasswordFlows(context, type, at)
1102
1201
  };
1103
1202
  }
1203
+ // src/auth/email-sign-in.ts
1204
+ async function signInHolder(context, type, email) {
1205
+ const record = await holderOfEmail(context, type, email);
1206
+ return record === null || !record.active ? null : record;
1207
+ }
1208
+ async function keepOnlyLatest(context, userId, kind, secret) {
1209
+ await context.store.tokens.spendUserTokens(userId, kind, context.clock.now(), hashSecret(secret));
1210
+ }
1211
+ async function finishEmailSignIn(context, type, finish, signIn) {
1212
+ const { user, token, where, noun } = signIn;
1213
+ refuseStale(type, user, token, where, noun);
1214
+ if (!user.active) {
1215
+ throw new UserInactiveError2(`${where}: the user is inactive`, {
1216
+ userId: user.id,
1217
+ userType: type.name
1218
+ });
1219
+ }
1220
+ if (user.emailVerifiedAt !== null)
1221
+ return finish(user, where);
1222
+ const proved = await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now }));
1223
+ await emit(context, "user.emailVerified", proved, proved.updatedAt);
1224
+ return finish(proved, where);
1225
+ }
1226
+
1227
+ // src/auth/magic-link.ts
1228
+ function magicLinkFlows(context, type, at, finish) {
1229
+ return {
1230
+ async request(email) {
1231
+ return requestLink(context, type, String(email));
1232
+ },
1233
+ async confirm(token) {
1234
+ return confirmLink(context, type, finish, String(token), at("magicLink.confirm"));
1235
+ }
1236
+ };
1237
+ }
1238
+ async function requestLink(context, type, email) {
1239
+ const record = await signInHolder(context, type, email);
1240
+ if (record === null)
1241
+ return null;
1242
+ const address = String(record.fields[type.email]);
1243
+ const { secret, expiresAt } = await issueOneTime(context, {
1244
+ kind: "magicLink",
1245
+ userId: record.id,
1246
+ address,
1247
+ ttlMs: context.config.tokenTtlMs.magicLink
1248
+ });
1249
+ await keepOnlyLatest(context, record.id, "magicLink", secret);
1250
+ return { token: secret, email: address, expiresAt, user: toUser(record) };
1251
+ }
1252
+ async function confirmLink(context, type, finish, secret, where) {
1253
+ const { token, user } = await redeemEmailToken(context, type, "magicLink", secret, where);
1254
+ return finishEmailSignIn(context, type, finish, {
1255
+ user,
1256
+ token,
1257
+ where,
1258
+ noun: "token"
1259
+ });
1260
+ }
1261
+
1262
+ // src/auth/derived-keys.ts
1263
+ import { createHmac, hkdfSync } from "node:crypto";
1264
+ var derived = new WeakMap;
1265
+ function derivedKey(sealer, purpose, keyId = sealer.sealWith) {
1266
+ let keys = derived.get(sealer);
1267
+ if (keys === undefined) {
1268
+ keys = new Map;
1269
+ derived.set(sealer, keys);
1270
+ }
1271
+ const name = `${purpose}\x00${keyId}`;
1272
+ let key = keys.get(name);
1273
+ if (key === undefined) {
1274
+ const secret = sealer.keys.get(keyId);
1275
+ if (secret === undefined)
1276
+ return;
1277
+ key = Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), purpose, 32));
1278
+ keys.set(name, key);
1279
+ }
1280
+ return key;
1281
+ }
1282
+ function keyedHash(key, parts) {
1283
+ return createHmac("sha256", key).update(parts.join("\x00")).digest();
1284
+ }
1285
+
1286
+ // src/auth/sign-in-attempts.ts
1287
+ var PURPOSE = "janus/sign-in/attempts/v1";
1288
+ var UNSEALED = Buffer.from(PURPOSE);
1289
+ function counterKey(context) {
1290
+ const sealer = context.config.secondFactor?.sealer;
1291
+ return sealer === undefined ? UNSEALED : derivedKey(sealer, PURPOSE);
1292
+ }
1293
+ var keyed = (key, parts) => keyedHash(key, parts).toString("base64url");
1294
+ async function countLogin(context, type, login, where) {
1295
+ const throttle = context.config.signInThrottle;
1296
+ if (throttle === null)
1297
+ return null;
1298
+ const key = counterKey(context);
1299
+ const loginKey = keyed(key, [type.name, login]);
1300
+ return countInWindow(context, {
1301
+ windowMs: throttle.windowMs,
1302
+ spent: "restart",
1303
+ userId: loginKey,
1304
+ linkHash: (window, link) => hashSecret(keyed(key, [loginKey, String(window), String(link)])),
1305
+ vanished: () => new StoreFailure2(`${where}: the store dropped the attempts it had just stored`, { slot: "tokens", operation: "countAttempt" })
1306
+ });
1307
+ }
1308
+ async function countSignInAttempt(context, type, login, where) {
1309
+ const counted = await countLogin(context, type, login, where);
1310
+ const limit = context.config.signInThrottle?.attempts;
1311
+ if (counted === null || limit === undefined || counted.attempts <= limit) {
1312
+ return;
1313
+ }
1314
+ const now = context.clock.now().getTime();
1315
+ throw new CredentialError2("CREDENTIALS_INVALID", `${where}: too many passwords tried at this login — wait for the next window`, {
1316
+ reason: "throttled",
1317
+ userType: type.name,
1318
+ retryAfter: Math.max(1, Math.ceil((counted.endsAt - now) / 1000))
1319
+ });
1320
+ }
1321
+ async function restartSignInCount(context, type, record, where) {
1322
+ const rule = type.password;
1323
+ const login = rule === null ? undefined : record.fields[rule.login];
1324
+ if (rule === null || typeof login !== "string")
1325
+ return;
1326
+ const counted = await countLogin(context, type, rule.normalize(login), where);
1327
+ if (counted?.link == null)
1328
+ return;
1329
+ await context.store.tokens.consumeToken(counted.link, "secondFactor", context.clock.now());
1330
+ }
1331
+
1104
1332
  // src/auth/totp.ts
1105
- import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
1333
+ import { createHmac as createHmac2, randomBytes as randomBytes4 } from "node:crypto";
1106
1334
  var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
1107
1335
  var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
1108
1336
  function mintTotpSecret() {
@@ -1145,7 +1373,7 @@ var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
1145
1373
  function codeAt(secret, step) {
1146
1374
  const counter = Buffer.alloc(8);
1147
1375
  counter.writeBigUInt64BE(BigInt(step));
1148
- const digest = createHmac("sha1", secret).update(counter).digest();
1376
+ const digest = createHmac2("sha1", secret).update(counter).digest();
1149
1377
  const offset = digest[19] & 15;
1150
1378
  const binary = digest.readUInt32BE(offset) & 2147483647;
1151
1379
  return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
@@ -1272,6 +1500,7 @@ async function confirmChallenge(context, type, challenge, code, at) {
1272
1500
  return refuseCode(context, type, opened, where);
1273
1501
  const written = await context.store.users.updateUser(record.id, { secondFactor: accepted, updatedAt: now }, record.version);
1274
1502
  await spendChallenge(context, opened.secret, where);
1503
+ await restartSignInCount(context, type, written, where);
1275
1504
  return openSession(context, type, written);
1276
1505
  }
1277
1506
 
@@ -1283,12 +1512,12 @@ async function recoveryCodesLeftOf(context, type, user, where) {
1283
1512
  }
1284
1513
 
1285
1514
  // src/auth/second-factor/recovery-codes.ts
1286
- import { createHmac as createHmac2, hkdfSync, randomInt as randomInt2, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
1515
+ import { randomInt as randomInt2, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
1287
1516
  var RECOVERY_CODES = 10;
1288
1517
  var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
1289
1518
  var LENGTH = 10;
1290
1519
  var SHAPE = /^[0-9a-hjkmnp-tv-z]{10}$/;
1291
- var PURPOSE = "janus/second-factor/recovery-codes/v1";
1520
+ var PURPOSE2 = "janus/second-factor/recovery-codes/v1";
1292
1521
  function mintRecoveryCodes() {
1293
1522
  return Array.from({ length: RECOVERY_CODES }, () => {
1294
1523
  let code = "";
@@ -1302,24 +1531,8 @@ function readRecoveryCode(typed) {
1302
1531
  const code = typed.toLowerCase().replace(/[\s-]/g, "").replace(/o/g, "0").replace(/[il]/g, "1");
1303
1532
  return SHAPE.test(code) ? code : null;
1304
1533
  }
1305
- var derived = new WeakMap;
1306
- function macKey(sealer, keyId) {
1307
- let keys = derived.get(sealer);
1308
- if (keys === undefined) {
1309
- keys = new Map;
1310
- derived.set(sealer, keys);
1311
- }
1312
- let key = keys.get(keyId);
1313
- if (key === undefined) {
1314
- const secret = sealer.keys.get(keyId);
1315
- if (secret === undefined)
1316
- return;
1317
- key = Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), PURPOSE, 32));
1318
- keys.set(keyId, key);
1319
- }
1320
- return key;
1321
- }
1322
- var mac = (key, userId, code) => createHmac2("sha256", key).update(`${userId}\x00${code}`).digest();
1534
+ var macKey = (sealer, keyId) => derivedKey(sealer, PURPOSE2, keyId);
1535
+ var mac = (key, userId, code) => keyedHash(key, [userId, code]);
1323
1536
  function hashRecoveryCode(sealer, userId, code) {
1324
1537
  const read = readRecoveryCode(code) ?? code;
1325
1538
  const key = macKey(sealer, sealer.sealWith);
@@ -1427,21 +1640,9 @@ function accountOf(type, record, where) {
1427
1640
  }
1428
1641
 
1429
1642
  // src/auth/second-factor/app-code-attempts.ts
1430
- import { createHmac as createHmac3, hkdfSync as hkdfSync2 } from "node:crypto";
1431
1643
  var APP_CODE_WINDOW_MS = 15 * 60000;
1432
1644
  var WINDOW_MINUTES = APP_CODE_WINDOW_MS / 60000;
1433
- var PURPOSE2 = "janus/second-factor/regenerate-attempts/v1";
1434
- var MAX_LINKS = 32;
1435
- var derived2 = new WeakMap;
1436
- function counterKey(sealer) {
1437
- let key = derived2.get(sealer);
1438
- if (key === undefined) {
1439
- const secret = sealer.keys.get(sealer.sealWith);
1440
- key = Buffer.from(hkdfSync2("sha256", secret, Buffer.alloc(0), PURPOSE2, 32));
1441
- derived2.set(sealer, key);
1442
- }
1443
- return key;
1444
- }
1645
+ var PURPOSE3 = "janus/second-factor/regenerate-attempts/v1";
1445
1646
  function linkHash(sealer, record, window, link) {
1446
1647
  const parts = [
1447
1648
  record.id,
@@ -1449,45 +1650,21 @@ function linkHash(sealer, record, window, link) {
1449
1650
  String(record.secondFactor?.lastStep ?? ""),
1450
1651
  String(link)
1451
1652
  ];
1452
- return hashSecret(createHmac3("sha256", counterKey(sealer)).update(parts.join("\x00")).digest("base64url"));
1653
+ const key = derivedKey(sealer, PURPOSE3);
1654
+ return hashSecret(keyedHash(key, parts).toString("base64url"));
1453
1655
  }
1454
1656
  async function countAppCodeAttempt(context, sealer, record, where) {
1455
- const now = context.clock.now().getTime();
1456
- const window = Math.floor(now / APP_CODE_WINDOW_MS);
1457
- const times = { now, expiresAt: (window + 2) * APP_CODE_WINDOW_MS };
1458
- let spent = 0;
1459
- for (let link = 0;link < MAX_LINKS; link += 1) {
1460
- const hash = linkHash(sealer, record, window, link);
1461
- const counted = await countLink(context, hash, record, times, where);
1462
- const attempts = spent + counted.attempts;
1463
- if (counted.spentAt === null)
1464
- return attemptsLeft(attempts, record, where);
1465
- spent = attempts;
1466
- }
1467
- return attemptsLeft(Number.POSITIVE_INFINITY, record, where);
1468
- }
1469
- async function countLink(context, tokenHash, record, times, where) {
1470
- const tokens = context.store.tokens;
1471
- const counted = await tokens.countAttempt(tokenHash, "secondFactor");
1472
- if (counted !== null)
1473
- return counted;
1474
- await tokens.insertToken({
1475
- tokenHash,
1476
- kind: "secondFactor",
1657
+ const { attempts } = await countInWindow(context, {
1658
+ windowMs: APP_CODE_WINDOW_MS,
1659
+ spent: "carry",
1477
1660
  userId: record.id,
1478
- address: "",
1479
- codeHash: null,
1480
- attempts: 0,
1481
- expiresAt: new Date(times.expiresAt),
1482
- spentAt: null,
1483
- createdAt: new Date(times.now)
1661
+ linkHash: (window, link) => linkHash(sealer, record, window, link),
1662
+ vanished: () => new StoreConflict2("version", `${where}: the user changed while the code was checked — read it again and retry`, {
1663
+ userId: record.id,
1664
+ expectedVersion: record.version,
1665
+ operation: where
1666
+ })
1484
1667
  });
1485
- const inserted = await tokens.countAttempt(tokenHash, "secondFactor");
1486
- if (inserted !== null)
1487
- return inserted;
1488
- 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 });
1489
- }
1490
- function attemptsLeft(attempts, record, where) {
1491
1668
  if (attempts > CODE_ATTEMPTS) {
1492
1669
  throw new TokenError2("CODE_INVALID", `${where}: too many codes tried — wait for the next ${WINDOW_MINUTES}-minute window`, {
1493
1670
  operation: where,
@@ -1558,6 +1735,7 @@ async function recoverWithCode(context, type, challenge, code, at) {
1558
1735
  }, record.version);
1559
1736
  try {
1560
1737
  await spendChallenge(context, opened.secret, where);
1738
+ await restartSignInCount(context, type, written, where);
1561
1739
  const signedIn = await openSession(context, type, written);
1562
1740
  return { ...signedIn, recoveryCodesLeft: recoveryCodes.length };
1563
1741
  } finally {
@@ -1592,8 +1770,8 @@ function signInCodeFlows(context, type, at, finish) {
1592
1770
  };
1593
1771
  }
1594
1772
  async function requestCode(context, type, email) {
1595
- const record = await holderOfEmail(context, type, email);
1596
- if (record === null || !record.active)
1773
+ const record = await signInHolder(context, type, email);
1774
+ if (record === null)
1597
1775
  return null;
1598
1776
  const { secret, code, expiresAt } = await issueCode(context, {
1599
1777
  kind: "signInCode",
@@ -1601,7 +1779,7 @@ async function requestCode(context, type, email) {
1601
1779
  address: String(record.fields[type.email]),
1602
1780
  ttlMs: context.config.tokenTtlMs.signInCode
1603
1781
  });
1604
- await context.store.tokens.spendUserTokens(record.id, "signInCode", context.clock.now(), hashSecret(secret));
1782
+ await keepOnlyLatest(context, record.id, "signInCode", secret);
1605
1783
  return {
1606
1784
  code,
1607
1785
  challenge: secret,
@@ -1625,18 +1803,12 @@ async function confirmCode(context, type, finish, challenge, code, at) {
1625
1803
  throw codeInvalid(where, user.id, type.name, attemptsLeft);
1626
1804
  }
1627
1805
  await spendOneTime(context, secret, "signInCode", where, "challenge");
1628
- refuseStale(type, user, token, where, "code");
1629
- if (!user.active) {
1630
- throw new UserInactiveError2(`${where}: the user is inactive`, {
1631
- userId: user.id,
1632
- userType: type.name
1633
- });
1634
- }
1635
- if (user.emailVerifiedAt !== null)
1636
- return finish(user, where);
1637
- const proved = await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now }));
1638
- await emit(context, "user.emailVerified", proved, proved.updatedAt);
1639
- return finish(proved, where);
1806
+ return finishEmailSignIn(context, type, finish, {
1807
+ user,
1808
+ token,
1809
+ where,
1810
+ noun: "code"
1811
+ });
1640
1812
  }
1641
1813
 
1642
1814
  // src/auth/step-up/codes.ts
@@ -1798,7 +1970,7 @@ async function changePassword(context, type, user, change, options, where) {
1798
1970
  }
1799
1971
  };
1800
1972
  });
1801
- await endWhatThePasswordOpened(context, written.id);
1973
+ await passwordChanged(context, written);
1802
1974
  return written;
1803
1975
  }
1804
1976
 
@@ -1840,7 +2012,11 @@ async function signIn(context, type, finish, input, where) {
1840
2012
  const hasher = requireHasher(context, where);
1841
2013
  const login = input?.[rule.login];
1842
2014
  const password = input?.password;
1843
- const record = typeof login === "string" ? await byLogin(context, type, rule.normalize(login)) : null;
2015
+ const normalized = typeof login === "string" ? rule.normalize(login) : null;
2016
+ if (normalized !== null) {
2017
+ await countSignInAttempt(context, type, normalized, where);
2018
+ }
2019
+ const record = normalized === null ? null : await byLogin(context, type, normalized);
1844
2020
  const refuse = (reason) => new CredentialError2("CREDENTIALS_INVALID", `${where}: the login and the password do not match`, { reason, userType: type.name });
1845
2021
  if (record === null || record.password === null) {
1846
2022
  await hasher.verify(String(password), await context.dummyHash());
@@ -1855,6 +2031,9 @@ async function signIn(context, type, finish, input, where) {
1855
2031
  userType: type.name
1856
2032
  });
1857
2033
  }
2034
+ if (!isActive(record.secondFactor)) {
2035
+ await restartSignInCount(context, type, record, where);
2036
+ }
1858
2037
  const verified = await rehashed(context, record, String(password));
1859
2038
  const result = await finish(verified, where);
1860
2039
  if (!await heldByPassword(context, type, verified, String(password), result, where)) {
@@ -1889,7 +2068,7 @@ function passwordFlows(context, type, at, finish) {
1889
2068
  checkPassword(type, password, where);
1890
2069
  const hash = await requireHasher(context, where).hash(password);
1891
2070
  const written = await writeUser(context, user, type, options, where, (_, now) => ({ password: { hash, updatedAt: now } }));
1892
- await endWhatThePasswordOpened(context, written.id);
2071
+ await passwordChanged(context, written);
1893
2072
  return toUser(written);
1894
2073
  },
1895
2074
  async changePassword(user, change, options) {
@@ -1923,19 +2102,36 @@ async function deleteUser(context, type, user) {
1923
2102
  }
1924
2103
 
1925
2104
  // src/auth/users/fields-patch.ts
2105
+ var normalized = (email) => email === null ? null : normalizeEmail(email);
2106
+ function emailChanged(type, before, after) {
2107
+ return normalized(emailOf(type, before)) !== normalized(emailOf(type, after));
2108
+ }
1926
2109
  async function fieldsPatch(type, record, patch, where) {
1927
2110
  const fields = await validateFields(type, { ...record.fields, ...patch }, where);
1928
- const before = emailOf(type, record.fields);
1929
- const after = emailOf(type, fields);
1930
- const emailChanged = (before === null ? null : normalizeEmail(before)) !== (after === null ? null : normalizeEmail(after));
1931
2111
  return {
1932
2112
  fields,
1933
2113
  logins: loginsOf(type, fields, where),
1934
2114
  schemaVersion: type.schemaVersion,
1935
- ...emailChanged ? { emailVerifiedAt: null } : {}
2115
+ ...emailChanged(type, record.fields, fields) ? { emailVerifiedAt: null } : {}
1936
2116
  };
1937
2117
  }
1938
2118
 
2119
+ // src/auth/users/update.ts
2120
+ async function update(context, type, user, patch, options, where) {
2121
+ const seen = { replaced: null };
2122
+ const written = await writeUser(context, user, type, options, where, (record) => {
2123
+ seen.replaced = record;
2124
+ return fieldsPatch(type, record, patch, where);
2125
+ });
2126
+ const { replaced } = seen;
2127
+ if (replaced !== null && emailChanged(type, replaced.fields, written.fields)) {
2128
+ await emit(context, "user.emailChanged", written, written.updatedAt, {
2129
+ formerEmail: emailOf(type, replaced.fields)
2130
+ });
2131
+ }
2132
+ return written;
2133
+ }
2134
+
1939
2135
  // src/auth/users/record-flows.ts
1940
2136
  function recordFlows(context, type, at) {
1941
2137
  const { store } = context;
@@ -1963,9 +2159,7 @@ function recordFlows(context, type, at) {
1963
2159
  return { items: answer.items.map(toUser), nextCursor: answer.nextCursor };
1964
2160
  },
1965
2161
  async update(user, patch, options) {
1966
- const where = at("update");
1967
- const written = await writeUser(context, user, type, options, where, (record) => fieldsPatch(type, record, patch, where));
1968
- return toUser(written);
2162
+ return toUser(await update(context, type, user, patch, options, at("update")));
1969
2163
  },
1970
2164
  async setActive(user, active, options) {
1971
2165
  return toUser(await writeUser(context, user, type, options, at("setActive"), () => ({
@@ -1988,6 +2182,7 @@ function typeApi(context, type) {
1988
2182
  ...passwordFlows(context, type, at, secondFactor.finish),
1989
2183
  secondFactor: secondFactor.api,
1990
2184
  signInCode,
2185
+ magicLink: magicLinkFlows(context, type, at, secondFactor.finish),
1991
2186
  stepUp: stepUpFlows(context, type, at),
1992
2187
  ...emailFlows(context, type, at)
1993
2188
  };
@@ -2075,5 +2270,5 @@ export {
2075
2270
  systemClock
2076
2271
  };
2077
2272
 
2078
- //# debugId=D61F9F351D899C2964756E2164756E21
2273
+ //# debugId=56E4EBD3ED51BF6B64756E2164756E21
2079
2274
  //# sourceMappingURL=index.js.map