@nxgt/janus 0.17.1 → 0.18.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 (71) hide show
  1. package/README.md +72 -18
  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 +31 -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-throttle.d.ts +30 -0
  8. package/dist/auth/config/resolve-throttle.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/config/warn-throttle-window.d.ts +14 -0
  12. package/dist/auth/config/warn-throttle-window.d.ts.map +1 -0
  13. package/dist/auth/counter-key.d.ts +19 -0
  14. package/dist/auth/counter-key.d.ts.map +1 -0
  15. package/dist/auth/email-flows/reset-password.d.ts.map +1 -1
  16. package/dist/auth/email-flows/verify-email.d.ts.map +1 -1
  17. package/dist/auth/index.d.ts +1 -1
  18. package/dist/auth/index.d.ts.map +1 -1
  19. package/dist/auth/janus.d.ts.map +1 -1
  20. package/dist/auth/magic-link.d.ts.map +1 -1
  21. package/dist/auth/mail-requests.d.ts +51 -0
  22. package/dist/auth/mail-requests.d.ts.map +1 -0
  23. package/dist/auth/sign-in-attempts.d.ts +3 -6
  24. package/dist/auth/sign-in-attempts.d.ts.map +1 -1
  25. package/dist/auth/sign-in-code.d.ts +3 -2
  26. package/dist/auth/sign-in-code.d.ts.map +1 -1
  27. package/dist/auth/step-up/request.d.ts.map +1 -1
  28. package/dist/auth/types/email-flows.d.ts +16 -6
  29. package/dist/auth/types/email-flows.d.ts.map +1 -1
  30. package/dist/auth/types/magic-link.d.ts +4 -2
  31. package/dist/auth/types/magic-link.d.ts.map +1 -1
  32. package/dist/auth/types/step-up.d.ts +4 -1
  33. package/dist/auth/types/step-up.d.ts.map +1 -1
  34. package/dist/chunks/{index-kj3st57z.js → index-4khsm7mw.js} +2 -2
  35. package/dist/chunks/{index-6c9fbtv2.js → index-cntabs9d.js} +2 -2
  36. package/dist/chunks/{index-hnwggyxs.js → index-kppkcbn5.js} +52 -49
  37. package/dist/chunks/{index-hnwggyxs.js.map → index-kppkcbn5.js.map} +4 -4
  38. package/dist/chunks/{index-baazjyvc.js → index-kvjj5xkp.js} +2 -2
  39. package/dist/conformance/index.js +3 -3
  40. package/dist/errors/janus-error/codes.d.ts +10 -0
  41. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  42. package/dist/errors/janus-error/index.d.ts +1 -1
  43. package/dist/errors/janus-error/index.d.ts.map +1 -1
  44. package/dist/errors/janus-error/options.d.ts +4 -3
  45. package/dist/errors/janus-error/options.d.ts.map +1 -1
  46. package/dist/errors/janus-error/refusals.d.ts +8 -0
  47. package/dist/errors/janus-error/refusals.d.ts.map +1 -1
  48. package/dist/errors/janus-error/status.d.ts +1 -1
  49. package/dist/errors/janus-error/status.d.ts.map +1 -1
  50. package/dist/index.d.ts +1 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +118 -29
  53. package/dist/index.js.map +18 -15
  54. package/dist/permissions/index.js +40 -29
  55. package/dist/permissions/index.js.map +6 -6
  56. package/docs/README.md +1 -1
  57. package/docs/guide/email-flows.md +164 -4
  58. package/docs/guide/errors.md +12 -3
  59. package/docs/guide/magic-link.md +41 -5
  60. package/docs/guide/passwords.md +2 -1
  61. package/docs/guide/sign-in-code.md +46 -7
  62. package/docs/guide/step-up.md +38 -7
  63. package/docs/guide/users.md +1 -0
  64. package/docs/roadmap.md +14 -1
  65. package/docs/troubleshooting.md +88 -3
  66. package/package.json +1 -1
  67. package/dist/auth/config/resolve-sign-in.d.ts +0 -5
  68. package/dist/auth/config/resolve-sign-in.d.ts.map +0 -1
  69. /package/dist/chunks/{index-kj3st57z.js.map → index-4khsm7mw.js.map} +0 -0
  70. /package/dist/chunks/{index-6c9fbtv2.js.map → index-cntabs9d.js.map} +0 -0
  71. /package/dist/chunks/{index-baazjyvc.js.map → index-kvjj5xkp.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-kj3st57z.js";
10
+ } from "./chunks/index-4khsm7mw.js";
11
11
  import {
12
12
  mintId2,
13
13
  isId2,
14
14
  mintedAt2,
15
15
  createMemoryStores2
16
- } from "./chunks/index-baazjyvc.js";
16
+ } from "./chunks/index-kvjj5xkp.js";
17
17
  import {
18
18
  JanusError2,
19
19
  UserInvalidError2,
@@ -22,6 +22,7 @@ import {
22
22
  TokenError2,
23
23
  SecondFactorError2,
24
24
  StepUpRequiredError2,
25
+ MailThrottledError2,
25
26
  InvalidCursorError2,
26
27
  UnsupportedError2,
27
28
  PermissionDepthError2,
@@ -38,7 +39,7 @@ import {
38
39
  formatTuple2,
39
40
  parseTuple2,
40
41
  parseSubject2
41
- } from "./chunks/index-hnwggyxs.js";
42
+ } from "./chunks/index-kppkcbn5.js";
42
43
 
43
44
  // src/auth/hashers.ts
44
45
  import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
@@ -303,25 +304,32 @@ function resolveSecondFactor(config, where) {
303
304
  };
304
305
  }
305
306
 
306
- // src/auth/config/resolve-sign-in.ts
307
+ // src/auth/config/resolve-throttle.ts
307
308
  function resolveSignInThrottle(config, where) {
309
+ return resolveThrottle(config, { option: "signIn", attempts: 10, window: "15m" }, where);
310
+ }
311
+ function resolveMailThrottle(config, where) {
312
+ return resolveThrottle(config, { option: "mail", attempts: 5, window: "10m" }, where);
313
+ }
314
+ function resolveThrottle(config, defaults, where) {
315
+ const { option } = defaults;
308
316
  if (config !== undefined && (typeof config !== "object" || config === null)) {
309
- throw new TypeError(`${where}: signIn must be an object — { throttle }`);
317
+ throw new TypeError(`${where}: ${option} must be an object — { throttle }`);
310
318
  }
311
319
  const throttle = config?.throttle ?? {};
312
320
  if (throttle === false)
313
321
  return null;
314
- const at = `${where}: signIn.throttle`;
322
+ const at = `${where}: ${option}.throttle`;
315
323
  if (typeof throttle !== "object" || throttle === null) {
316
324
  throw new TypeError(`${at} must be { attempts, window }, or false to count nothing`);
317
325
  }
318
- const attempts = throttle.attempts ?? 10;
326
+ const attempts = throttle.attempts ?? defaults.attempts;
319
327
  if (!Number.isSafeInteger(attempts) || attempts < 1) {
320
328
  throw new TypeError(`${at}.attempts must be a whole number above zero`);
321
329
  }
322
330
  return {
323
331
  attempts,
324
- windowMs: parseDuration(throttle.window ?? "15m", `${at}.window`)
332
+ windowMs: parseDuration(throttle.window ?? defaults.window, `${at}.window`)
325
333
  };
326
334
  }
327
335
 
@@ -372,6 +380,7 @@ function resolveConfig(config, where) {
372
380
  tokenTtlMs: resolveTokenTtls(config, where),
373
381
  cookie: resolveCookie(config.cookie ?? {}, where),
374
382
  signInThrottle: resolveSignInThrottle(config.signIn, where),
383
+ mailThrottle: resolveMailThrottle(config.mail, where),
375
384
  secondFactor: resolveSecondFactor(config.secondFactor, where),
376
385
  devices: resolveDevices(config.devices, where)
377
386
  };
@@ -419,6 +428,46 @@ function resolveTokenTtls(config, where) {
419
428
  stepUp: parseDuration(config.tokens?.stepUp ?? "10m", `${where}: tokens.stepUp`)
420
429
  };
421
430
  }
431
+ // src/auth/config/warn-throttle-window.ts
432
+ var FLOWS = [
433
+ "magicLink",
434
+ "signInCode",
435
+ "resetPassword",
436
+ "verifyEmail",
437
+ "stepUp"
438
+ ];
439
+ var UNITS2 = [
440
+ ["d", 86400000],
441
+ ["h", 3600000],
442
+ ["m", 60000],
443
+ ["s", 1000]
444
+ ];
445
+ function show(ms) {
446
+ const unit = UNITS2.find(([, size]) => ms % size === 0);
447
+ return unit === undefined ? `${ms}ms` : `${ms / unit[1]}${unit[0]}`;
448
+ }
449
+ function throttleWindowMessage(resolved, where) {
450
+ const throttle = resolved.mailThrottle;
451
+ if (throttle === null)
452
+ return null;
453
+ const window = show(throttle.windowMs);
454
+ const shorter = FLOWS.filter((flow) => resolved.tokenTtlMs[flow] < throttle.windowMs);
455
+ if (shorter.length === 0)
456
+ return null;
457
+ const shortest = Math.min(...shorter.map((flow) => resolved.tokenTtlMs[flow]));
458
+ const parts = shorter.map((flow) => `tokens.${flow} is ${show(resolved.tokenTtlMs[flow])}, shorter than mail.throttle.window, ${window}`);
459
+ return `${where}: ${parts.join("; ")} — past the limit the last ${shorter.length === 1 ? "token" : "tokens"} sent can expire before the refusal ends. Set mail.throttle.window to ${show(shortest)} or less, or raise ${shorter.map((flow) => `tokens.${flow}`).join(", ")} to ${window} or more.`;
460
+ }
461
+ function warnThrottleWindow(resolved, where) {
462
+ const message = throttleWindowMessage(resolved, where);
463
+ if (message === null)
464
+ return;
465
+ if (typeof process === "undefined" || typeof process.emitWarning !== "function") {
466
+ return;
467
+ }
468
+ process.emitWarning(message, { code: "JANUS_THROTTLE_WINDOW" });
469
+ }
470
+
422
471
  // src/auth/context/create-context.ts
423
472
  function createContext(config, store, relations, capabilities, clock, hasher, verifiers, events = null) {
424
473
  let dummy = null;
@@ -726,7 +775,7 @@ function assertStores(stores, where) {
726
775
  }
727
776
  }
728
777
  const sessions = stores.sessions;
729
- const collect = sessions.deleteExpiredSessions;
778
+ const collect = sessions["deleteExpiredSessions"];
730
779
  if (collect !== undefined && typeof collect !== "function") {
731
780
  throw new TypeError(`${where}: store.sessions.deleteExpiredSessions must be a function or absent`);
732
781
  }
@@ -1008,6 +1057,13 @@ function sharedApi(context) {
1008
1057
  types: [...config.types.keys()]
1009
1058
  };
1010
1059
  }
1060
+ // src/auth/counter-key.ts
1061
+ function counterKey(context, purpose) {
1062
+ const sealer = context.config.secondFactor?.sealer;
1063
+ return sealer === undefined ? Buffer.from(purpose) : derivedKey(sealer, purpose);
1064
+ }
1065
+ var keyed = (key, parts) => keyedHash(key, parts).toString("base64url");
1066
+
1011
1067
  // src/auth/one-time/codes.ts
1012
1068
  import { timingSafeEqual as timingSafeEqual3 } from "node:crypto";
1013
1069
 
@@ -1173,6 +1229,38 @@ async function insertLink(links, tokenHash) {
1173
1229
  return inserted;
1174
1230
  throw links.counting.vanished();
1175
1231
  }
1232
+ // src/auth/mail-requests.ts
1233
+ var PURPOSE2 = "janus/mail/requests/v1";
1234
+ async function countMailByAddress(context, type, flow, email, where) {
1235
+ await countMail(context, type, flow, { address: normalizeEmail(email) }, where);
1236
+ }
1237
+ async function countMailByUser(context, type, flow, userId, where) {
1238
+ await countMail(context, type, flow, { userId }, where);
1239
+ }
1240
+ async function countMail(context, type, flow, counted, where) {
1241
+ const throttle = context.config.mailThrottle;
1242
+ if (throttle === null)
1243
+ return;
1244
+ const key = counterKey(context, PURPOSE2);
1245
+ const subject = "address" in counted ? keyed(key, [flow, "address", counted.address]) : keyed(key, [flow, "user", counted.userId]);
1246
+ const { attempts, endsAt } = await countInWindow(context, {
1247
+ windowMs: throttle.windowMs,
1248
+ spent: "carry",
1249
+ userId: subject,
1250
+ linkHash: (window, link) => hashSecret(keyed(key, [subject, String(window), String(link)])),
1251
+ vanished: () => new StoreFailure2(`${where}: the store dropped the requests it had just counted`, { slot: "tokens", operation: "countAttempt" })
1252
+ });
1253
+ if (attempts <= throttle.attempts)
1254
+ return;
1255
+ const now = context.clock.now().getTime();
1256
+ const byUser = "userId" in counted;
1257
+ throw new MailThrottledError2(`${where}: too many e-mails asked for this ${byUser ? "user" : "address"} — the last one sent still works; use it, or wait for the next window`, {
1258
+ userType: type.name,
1259
+ ...byUser ? { userId: counted.userId } : {},
1260
+ retryAfter: Math.max(1, Math.ceil((endsAt - now) / 1000))
1261
+ });
1262
+ }
1263
+
1176
1264
  // src/auth/password-written.ts
1177
1265
  async function endWhatThePasswordOpened(context, userId) {
1178
1266
  const now = context.clock.now();
@@ -1234,6 +1322,7 @@ function resetPasswordFlows(context, type, at) {
1234
1322
  async request(email) {
1235
1323
  const where = at("resetPassword.request");
1236
1324
  passwordRule(type, where);
1325
+ await countMailByAddress(context, type, "resetPassword", String(email), where);
1237
1326
  const record = await holderOfEmail(context, type, String(email));
1238
1327
  if (record === null)
1239
1328
  return null;
@@ -1278,6 +1367,7 @@ function verifyEmailFlows(context, type, at) {
1278
1367
  async send(user) {
1279
1368
  const where = at("verifyEmail.send");
1280
1369
  const record = await getRecord(context, idOf(user), type.name, where);
1370
+ await countMailByUser(context, type, "verifyEmail", record.id, where);
1281
1371
  return issueEmailToken(context, type, "verifyEmail", record, where);
1282
1372
  },
1283
1373
  async confirm(secret) {
@@ -1465,7 +1555,7 @@ async function finishEmailSignIn(context, type, finish, signIn) {
1465
1555
  function magicLinkFlows(context, type, at, finish) {
1466
1556
  return {
1467
1557
  async request(email) {
1468
- return requestLink(context, type, String(email));
1558
+ return requestLink(context, type, String(email), at("magicLink.request"));
1469
1559
  },
1470
1560
  async confirm(token, options) {
1471
1561
  const where = at("magicLink.confirm");
@@ -1474,7 +1564,8 @@ function magicLinkFlows(context, type, at, finish) {
1474
1564
  }
1475
1565
  };
1476
1566
  }
1477
- async function requestLink(context, type, email) {
1567
+ async function requestLink(context, type, email, where) {
1568
+ await countMailByAddress(context, type, "magicLink", email, where);
1478
1569
  const record = await signInHolder(context, type, email);
1479
1570
  if (record === null)
1480
1571
  return null;
@@ -1500,18 +1591,12 @@ async function confirmLink(context, type, finish, secret, where, device) {
1500
1591
  }
1501
1592
 
1502
1593
  // src/auth/sign-in-attempts.ts
1503
- var PURPOSE2 = "janus/sign-in/attempts/v1";
1504
- var UNSEALED = Buffer.from(PURPOSE2);
1505
- function counterKey(context) {
1506
- const sealer = context.config.secondFactor?.sealer;
1507
- return sealer === undefined ? UNSEALED : derivedKey(sealer, PURPOSE2);
1508
- }
1509
- var keyed = (key, parts) => keyedHash(key, parts).toString("base64url");
1594
+ var PURPOSE3 = "janus/sign-in/attempts/v1";
1510
1595
  async function countLogin(context, type, login, where) {
1511
1596
  const throttle = context.config.signInThrottle;
1512
1597
  if (throttle === null)
1513
1598
  return null;
1514
- const key = counterKey(context);
1599
+ const key = counterKey(context, PURPOSE3);
1515
1600
  const loginKey = keyed(key, [type.name, login]);
1516
1601
  return countInWindow(context, {
1517
1602
  windowMs: throttle.windowMs,
@@ -1634,7 +1719,7 @@ var RECOVERY_CODES = 10;
1634
1719
  var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
1635
1720
  var LENGTH = 10;
1636
1721
  var SHAPE = /^[0-9a-hjkmnp-tv-z]{10}$/;
1637
- var PURPOSE3 = "janus/second-factor/recovery-codes/v1";
1722
+ var PURPOSE4 = "janus/second-factor/recovery-codes/v1";
1638
1723
  function mintRecoveryCodes() {
1639
1724
  return Array.from({ length: RECOVERY_CODES }, () => {
1640
1725
  let code = "";
@@ -1648,7 +1733,7 @@ function readRecoveryCode(typed) {
1648
1733
  const code = typed.toLowerCase().replace(/[\s-]/g, "").replace(/o/g, "0").replace(/[il]/g, "1");
1649
1734
  return SHAPE.test(code) ? code : null;
1650
1735
  }
1651
- var macKey = (sealer, keyId) => derivedKey(sealer, PURPOSE3, keyId);
1736
+ var macKey = (sealer, keyId) => derivedKey(sealer, PURPOSE4, keyId);
1652
1737
  var mac = (key, userId, code) => keyedHash(key, [userId, code]);
1653
1738
  function hashRecoveryCode(sealer, userId, code) {
1654
1739
  const read = readRecoveryCode(code) ?? code;
@@ -1759,7 +1844,7 @@ function accountOf(type, record, where) {
1759
1844
  // src/auth/second-factor/app-code-attempts.ts
1760
1845
  var APP_CODE_WINDOW_MS = 15 * 60000;
1761
1846
  var WINDOW_MINUTES = APP_CODE_WINDOW_MS / 60000;
1762
- var PURPOSE4 = "janus/second-factor/regenerate-attempts/v1";
1847
+ var PURPOSE5 = "janus/second-factor/regenerate-attempts/v1";
1763
1848
  function linkHash(sealer, record, window, link) {
1764
1849
  const parts = [
1765
1850
  record.id,
@@ -1767,7 +1852,7 @@ function linkHash(sealer, record, window, link) {
1767
1852
  String(record.secondFactor?.lastStep ?? ""),
1768
1853
  String(link)
1769
1854
  ];
1770
- const key = derivedKey(sealer, PURPOSE4);
1855
+ const key = derivedKey(sealer, PURPOSE5);
1771
1856
  return hashSecret(keyedHash(key, parts).toString("base64url"));
1772
1857
  }
1773
1858
  async function countAppCodeAttempt(context, sealer, record, where) {
@@ -1883,14 +1968,15 @@ function secondFactorFlows(context, type, at) {
1883
1968
  function signInCodeFlows(context, type, at, finish) {
1884
1969
  return {
1885
1970
  async request(email) {
1886
- return requestCode(context, type, String(email));
1971
+ return requestCode(context, type, String(email), at("signInCode.request"));
1887
1972
  },
1888
1973
  async confirm(challenge, code, options) {
1889
1974
  return confirmCode(context, type, finish, { challenge, code, options }, at);
1890
1975
  }
1891
1976
  };
1892
1977
  }
1893
- async function requestCode(context, type, email) {
1978
+ async function requestCode(context, type, email, where) {
1979
+ await countMailByAddress(context, type, "signInCode", email, where);
1894
1980
  const record = await signInHolder(context, type, email);
1895
1981
  if (record === null)
1896
1982
  return null;
@@ -2028,6 +2114,7 @@ async function byEmail(context, type, record, where) {
2028
2114
  operation: where
2029
2115
  });
2030
2116
  }
2117
+ await countMailByUser(context, type, "stepUp", record.id, where);
2031
2118
  const { secret, code, expiresAt } = await issueCode(context, {
2032
2119
  kind: "stepUp",
2033
2120
  userId: record.id,
@@ -2136,7 +2223,7 @@ async function signIn(context, type, finish, input, call) {
2136
2223
  const rule = passwordRule(type, where);
2137
2224
  const hasher = requireHasher(context, where);
2138
2225
  const login = input?.[rule.login];
2139
- const password = input?.password;
2226
+ const password = input?.["password"];
2140
2227
  const normalized = typeof login === "string" ? rule.normalize(login) : null;
2141
2228
  if (normalized !== null) {
2142
2229
  await countSignInAttempt(context, type, normalized, where);
@@ -2175,7 +2262,7 @@ function passwordFlows(context, type, at, finish) {
2175
2262
  const where = at("signUp");
2176
2263
  const device = deviceHint(context, options, where);
2177
2264
  passwordRule(type, where);
2178
- if (input?.password === undefined) {
2265
+ if (input?.["password"] === undefined) {
2179
2266
  checkPassword(type, undefined, where);
2180
2267
  }
2181
2268
  const record = await insert(context, type, input, where);
@@ -2320,6 +2407,7 @@ function typeApi(context, type) {
2320
2407
  function janus(config) {
2321
2408
  const where = "janus";
2322
2409
  const resolved = resolveConfig(config, where);
2410
+ warnThrottleWindow(resolved, where);
2323
2411
  const capabilities = assertStores(config.store, where);
2324
2412
  const hasher = config.hasher ?? null;
2325
2413
  const verifiers = [
@@ -2346,7 +2434,7 @@ function janus(config) {
2346
2434
  type.name,
2347
2435
  typeApi(context, type)
2348
2436
  ]));
2349
- const surface = resolved.single ? { ...shared, ...types.user } : { ...shared, ...types };
2437
+ const surface = resolved.single ? { ...shared, ...types["user"] } : { ...shared, ...types };
2350
2438
  return surface;
2351
2439
  }
2352
2440
  // src/auth/sessions/fresh.ts
@@ -2363,6 +2451,7 @@ export {
2363
2451
  InvalidCursorError2 as InvalidCursorError,
2364
2452
  JanusError2 as JanusError,
2365
2453
  MAX_PAGE_SIZE2 as MAX_PAGE_SIZE,
2454
+ MailThrottledError2 as MailThrottledError,
2366
2455
  NotFoundError2 as NotFoundError,
2367
2456
  PermissionDepthError2 as PermissionDepthError,
2368
2457
  SecondFactorError2 as SecondFactorError,
@@ -2399,5 +2488,5 @@ export {
2399
2488
  systemClock
2400
2489
  };
2401
2490
 
2402
- //# debugId=735288C311F315A564756E2164756E21
2491
+ //# debugId=ACD85BEBE6A087E464756E2164756E21
2403
2492
  //# sourceMappingURL=index.js.map