@nxgt/janus 0.10.0 → 0.12.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 (86) hide show
  1. package/README.md +118 -18
  2. package/dist/auth/config/janus-config.d.ts +2 -0
  3. package/dist/auth/config/janus-config.d.ts.map +1 -1
  4. package/dist/auth/config/resolved-config.d.ts +1 -0
  5. package/dist/auth/config/resolved-config.d.ts.map +1 -1
  6. package/dist/auth/index.d.ts +2 -1
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  9. package/dist/auth/port/memory/sessions.d.ts.map +1 -1
  10. package/dist/auth/port/types/sessions.d.ts +16 -1
  11. package/dist/auth/port/types/sessions.d.ts.map +1 -1
  12. package/dist/auth/port/types/tokens.d.ts +7 -2
  13. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  14. package/dist/auth/second-factor/{regenerate-attempts.d.ts → app-code-attempts.d.ts} +13 -10
  15. package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -0
  16. package/dist/auth/second-factor/codes-left.d.ts +14 -0
  17. package/dist/auth/second-factor/codes-left.d.ts.map +1 -0
  18. package/dist/auth/second-factor/flows.d.ts +1 -0
  19. package/dist/auth/second-factor/flows.d.ts.map +1 -1
  20. package/dist/auth/sessions/authenticate.d.ts.map +1 -1
  21. package/dist/auth/sessions/fresh.d.ts +26 -0
  22. package/dist/auth/sessions/fresh.d.ts.map +1 -0
  23. package/dist/auth/sessions/standing.d.ts +20 -0
  24. package/dist/auth/sessions/standing.d.ts.map +1 -0
  25. package/dist/auth/step-up/codes.d.ts +29 -0
  26. package/dist/auth/step-up/codes.d.ts.map +1 -0
  27. package/dist/auth/step-up/flows.d.ts +19 -0
  28. package/dist/auth/step-up/flows.d.ts.map +1 -0
  29. package/dist/auth/step-up/open.d.ts +23 -0
  30. package/dist/auth/step-up/open.d.ts.map +1 -0
  31. package/dist/auth/step-up/request.d.ts +12 -0
  32. package/dist/auth/step-up/request.d.ts.map +1 -0
  33. package/dist/auth/types/index.d.ts +1 -0
  34. package/dist/auth/types/index.d.ts.map +1 -1
  35. package/dist/auth/types/janus.d.ts +2 -1
  36. package/dist/auth/types/janus.d.ts.map +1 -1
  37. package/dist/auth/types/second-factor.d.ts +9 -0
  38. package/dist/auth/types/second-factor.d.ts.map +1 -1
  39. package/dist/auth/types/step-up.d.ts +79 -0
  40. package/dist/auth/types/step-up.d.ts.map +1 -0
  41. package/dist/auth/users/any-type-api.d.ts +2 -2
  42. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  43. package/dist/auth/users/type-api.d.ts +1 -1
  44. package/dist/auth/users/type-api.d.ts.map +1 -1
  45. package/dist/chunks/{index-5e2fw7he.js → index-j2gd8fry.js} +12 -3
  46. package/dist/chunks/{index-5e2fw7he.js.map → index-j2gd8fry.js.map} +4 -4
  47. package/dist/chunks/{index-5tm04t5p.js → index-jp2kka6d.js} +2 -2
  48. package/dist/chunks/{index-6f6wja93.js → index-km7ffctx.js} +2 -2
  49. package/dist/chunks/{index-4m49cye4.js → index-rd2rddvv.js} +15 -12
  50. package/dist/chunks/{index-4m49cye4.js.map → index-rd2rddvv.js.map} +3 -3
  51. package/dist/conformance/cases/outage.d.ts.map +1 -1
  52. package/dist/conformance/cases/sessions/index.d.ts.map +1 -1
  53. package/dist/conformance/cases/sessions/reauthentication.d.ts +4 -0
  54. package/dist/conformance/cases/sessions/reauthentication.d.ts.map +1 -0
  55. package/dist/conformance/cases/tokens/index.d.ts.map +1 -1
  56. package/dist/conformance/cases/tokens/kinds.d.ts +4 -0
  57. package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -0
  58. package/dist/conformance/index.js +147 -60
  59. package/dist/conformance/index.js.map +8 -6
  60. package/dist/errors/janus-error/codes.d.ts +7 -0
  61. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  62. package/dist/errors/janus-error/index.d.ts +1 -1
  63. package/dist/errors/janus-error/index.d.ts.map +1 -1
  64. package/dist/errors/janus-error/refusals.d.ts +8 -0
  65. package/dist/errors/janus-error/refusals.d.ts.map +1 -1
  66. package/dist/errors/janus-error/status.d.ts.map +1 -1
  67. package/dist/index.d.ts +1 -1
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +184 -19
  70. package/dist/index.js.map +18 -11
  71. package/dist/permissions/index.js +3 -3
  72. package/docs/README.md +1 -0
  73. package/docs/guide/adapters.md +38 -4
  74. package/docs/guide/errors.md +1 -0
  75. package/docs/guide/events.md +21 -8
  76. package/docs/guide/second-factor.md +45 -16
  77. package/docs/guide/sessions.md +7 -0
  78. package/docs/guide/step-up.md +178 -0
  79. package/docs/guide/users.md +1 -0
  80. package/docs/guide/vocabulary.md +3 -2
  81. package/docs/roadmap.md +18 -23
  82. package/docs/troubleshooting.md +183 -8
  83. package/package.json +1 -1
  84. package/dist/auth/second-factor/regenerate-attempts.d.ts.map +0 -1
  85. /package/dist/chunks/{index-5tm04t5p.js.map → index-jp2kka6d.js.map} +0 -0
  86. /package/dist/chunks/{index-6f6wja93.js.map → index-km7ffctx.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-5tm04t5p.js";
10
+ } from "./chunks/index-jp2kka6d.js";
11
11
  import {
12
12
  mintId2,
13
13
  isId2,
14
14
  mintedAt2,
15
15
  createMemoryStores2
16
- } from "./chunks/index-4m49cye4.js";
16
+ } from "./chunks/index-rd2rddvv.js";
17
17
  import {
18
18
  JanusError2,
19
19
  UserInvalidError2,
@@ -21,6 +21,7 @@ import {
21
21
  UserInactiveError2,
22
22
  TokenError2,
23
23
  SecondFactorError2,
24
+ StepUpRequiredError2,
24
25
  InvalidCursorError2,
25
26
  UnsupportedError2,
26
27
  PermissionDepthError2,
@@ -37,7 +38,7 @@ import {
37
38
  formatTuple2,
38
39
  parseTuple2,
39
40
  parseSubject2
40
- } from "./chunks/index-5e2fw7he.js";
41
+ } from "./chunks/index-j2gd8fry.js";
41
42
 
42
43
  // src/auth/hashers.ts
43
44
  import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
@@ -382,7 +383,8 @@ function resolveTokenTtls(config, where) {
382
383
  return {
383
384
  verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
384
385
  resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`),
385
- signInCode: parseDuration(config.tokens?.signInCode ?? "10m", `${where}: tokens.signInCode`)
386
+ signInCode: parseDuration(config.tokens?.signInCode ?? "10m", `${where}: tokens.signInCode`),
387
+ stepUp: parseDuration(config.tokens?.stepUp ?? "10m", `${where}: tokens.stepUp`)
386
388
  };
387
389
  }
388
390
  // src/auth/context/create-context.ts
@@ -662,6 +664,7 @@ var REQUIRED = {
662
664
  "insertSession",
663
665
  "findSessionByTokenHash",
664
666
  "extendSession",
667
+ "reauthenticateSession",
665
668
  "revokeSession",
666
669
  "revokeUserSessions",
667
670
  "deleteUserSessions"
@@ -774,17 +777,24 @@ function headersOf(request) {
774
777
  return inner;
775
778
  return request;
776
779
  }
780
+ // src/auth/sessions/standing.ts
781
+ async function standingSession(context, request) {
782
+ const token = presentedToken(request, context.config.cookie.name);
783
+ if (token === null)
784
+ return null;
785
+ const session = await context.store.sessions.findSessionByTokenHash(hashSecret(token));
786
+ const now = context.clock.now().getTime();
787
+ return session === null || session.revokedAt !== null || session.expiresAt.getTime() <= now ? null : { token, session };
788
+ }
789
+
777
790
  // src/auth/sessions/authenticate.ts
778
791
  async function authenticate(context, request, options) {
779
792
  const { store, clock, config } = context;
780
- const token = presentedToken(request, config.cookie.name);
781
- if (token === null)
793
+ const presented = await standingSession(context, request);
794
+ if (presented === null)
782
795
  return null;
783
- const session = await store.sessions.findSessionByTokenHash(hashSecret(token));
796
+ const { token, session } = presented;
784
797
  const now = clock.now().getTime();
785
- if (session === null || session.revokedAt !== null || session.expiresAt.getTime() <= now) {
786
- return null;
787
- }
788
798
  const user = await store.users.findUser(session.userId);
789
799
  if (user === null || !user.active || options?.type !== undefined && user.type !== options.type) {
790
800
  return null;
@@ -1260,6 +1270,13 @@ async function confirmChallenge(context, type, challenge, code, at) {
1260
1270
  return openSession(context, type, written);
1261
1271
  }
1262
1272
 
1273
+ // src/auth/second-factor/codes-left.ts
1274
+ async function recoveryCodesLeftOf(context, type, user, where) {
1275
+ const record = await getRecord(context, idOf(user), type.name, where);
1276
+ const factor = record.secondFactor;
1277
+ return isActive(factor) ? factor.recoveryCodes.length : null;
1278
+ }
1279
+
1263
1280
  // src/auth/second-factor/recovery-codes.ts
1264
1281
  import { createHmac as createHmac2, hkdfSync, randomInt as randomInt2, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
1265
1282
  var RECOVERY_CODES = 10;
@@ -1404,10 +1421,10 @@ function accountOf(type, record, where) {
1404
1421
  return String(record.fields[type.password.login]);
1405
1422
  }
1406
1423
 
1407
- // src/auth/second-factor/regenerate-attempts.ts
1424
+ // src/auth/second-factor/app-code-attempts.ts
1408
1425
  import { createHmac as createHmac3, hkdfSync as hkdfSync2 } from "node:crypto";
1409
- var REGENERATE_WINDOW_MS = 15 * 60000;
1410
- var WINDOW_MINUTES = REGENERATE_WINDOW_MS / 60000;
1426
+ var APP_CODE_WINDOW_MS = 15 * 60000;
1427
+ var WINDOW_MINUTES = APP_CODE_WINDOW_MS / 60000;
1411
1428
  var PURPOSE2 = "janus/second-factor/regenerate-attempts/v1";
1412
1429
  var MAX_LINKS = 32;
1413
1430
  var derived2 = new WeakMap;
@@ -1429,10 +1446,10 @@ function linkHash(sealer, record, window, link) {
1429
1446
  ];
1430
1447
  return hashSecret(createHmac3("sha256", counterKey(sealer)).update(parts.join("\x00")).digest("base64url"));
1431
1448
  }
1432
- async function countRegenerateAttempt(context, sealer, record, where) {
1449
+ async function countAppCodeAttempt(context, sealer, record, where) {
1433
1450
  const now = context.clock.now().getTime();
1434
- const window = Math.floor(now / REGENERATE_WINDOW_MS);
1435
- const times = { now, expiresAt: (window + 2) * REGENERATE_WINDOW_MS };
1451
+ const window = Math.floor(now / APP_CODE_WINDOW_MS);
1452
+ const times = { now, expiresAt: (window + 2) * APP_CODE_WINDOW_MS };
1436
1453
  let spent = 0;
1437
1454
  for (let link = 0;link < MAX_LINKS; link += 1) {
1438
1455
  const hash = linkHash(sealer, record, window, link);
@@ -1486,7 +1503,7 @@ async function regenerateRecoveryCodes(context, type, user, code, options, where
1486
1503
  if (!isActive(factor)) {
1487
1504
  throw refusal(type, "SECOND_FACTOR_NOT_ENROLLED", "the user has no active second factor — recovery codes come with one", where, record);
1488
1505
  }
1489
- const left = await countRegenerateAttempt(context, configured.sealer, record, where);
1506
+ const left = await countAppCodeAttempt(context, configured.sealer, record, where);
1490
1507
  const accepted = acceptCode(configured, record, factor, String(code), now, where);
1491
1508
  if (accepted === null) {
1492
1509
  throw codeInvalid(where, record.id, type.name, left);
@@ -1549,7 +1566,8 @@ function secondFactorFlows(context, type, at) {
1549
1566
  const api = {
1550
1567
  ...lifecycleFlows(context, type, at),
1551
1568
  confirm: challenges.confirm,
1552
- recover: (challenge, code) => recoverWithCode(context, type, challenge, code, at)
1569
+ recover: (challenge, code) => recoverWithCode(context, type, challenge, code, at),
1570
+ recoveryCodesLeft: (user) => recoveryCodesLeftOf(context, type, user, at("secondFactor.recoveryCodesLeft"))
1553
1571
  };
1554
1572
  return {
1555
1573
  api,
@@ -1616,6 +1634,142 @@ async function confirmCode(context, type, finish, challenge, code, at) {
1616
1634
  return finish(proved, where);
1617
1635
  }
1618
1636
 
1637
+ // src/auth/step-up/codes.ts
1638
+ var spend = (context, secret, where) => spendOneTime(context, secret, "stepUp", where, "challenge");
1639
+ async function checkEmailedCode(context, type, user, counted, code, where) {
1640
+ const { token, secret, attemptsLeft } = counted;
1641
+ if (isActive(user.secondFactor)) {
1642
+ await spend(context, secret, where);
1643
+ throw new SecondFactorError2("SECOND_FACTOR_ACTIVE", `${where}: the user's second factor became active since the code was sent — request a step-up again`, { operation: where, userId: user.id, userType: type.name });
1644
+ }
1645
+ if (!codeMatches(token, secret, code)) {
1646
+ if (attemptsLeft === 0)
1647
+ await burnOneTime(context, secret, "stepUp");
1648
+ throw codeInvalid(where, user.id, type.name, attemptsLeft);
1649
+ }
1650
+ await spend(context, secret, where);
1651
+ refuseStale(type, user, token, where, "code");
1652
+ }
1653
+ async function checkAppCode(context, type, user, counted, code, where) {
1654
+ const { secret, attemptsLeft } = counted;
1655
+ const factor = user.secondFactor;
1656
+ if (!isActive(factor)) {
1657
+ await spend(context, secret, where);
1658
+ throw new SecondFactorError2("SECOND_FACTOR_NOT_ENROLLED", `${where}: the user no longer has a second factor — request a step-up again`, { operation: where, userId: user.id, userType: type.name });
1659
+ }
1660
+ const configured = requireSettings(context, where, "the user's second factor is active");
1661
+ const windowLeft = await countAppCodeAttempt(context, configured.sealer, user, where);
1662
+ const now = context.clock.now();
1663
+ const accepted = acceptCode(configured, user, factor, code, now, where);
1664
+ if (accepted === null) {
1665
+ if (attemptsLeft === 0)
1666
+ await burnOneTime(context, secret, "stepUp");
1667
+ throw codeInvalid(where, user.id, type.name, Math.min(attemptsLeft, windowLeft));
1668
+ }
1669
+ await context.store.users.updateUser(user.id, { secondFactor: accepted, updatedAt: now }, user.version);
1670
+ await spend(context, secret, where);
1671
+ }
1672
+
1673
+ // src/auth/step-up/open.ts
1674
+ async function openStepUp(context, type, request, token, secret, where) {
1675
+ const session = (await standingSession(context, request))?.session ?? null;
1676
+ if (session === null || session.userId !== token.userId) {
1677
+ throw await unknownChallenge(context, token, secret, where);
1678
+ }
1679
+ const user = await findRecord(context, token.userId, type.name);
1680
+ if (user === null) {
1681
+ throw await unknownChallenge(context, token, secret, where);
1682
+ }
1683
+ if (!user.active) {
1684
+ await spendOneTime(context, secret, "stepUp", where, "challenge");
1685
+ throw new UserInactiveError2(`${where}: the user is inactive`, {
1686
+ userId: user.id,
1687
+ userType: type.name
1688
+ });
1689
+ }
1690
+ return { session, user };
1691
+ }
1692
+
1693
+ // src/auth/step-up/request.ts
1694
+ async function requestStepUp(context, type, user, where) {
1695
+ const record = await getRecord(context, idOf(user), type.name, where);
1696
+ if (!record.active) {
1697
+ throw new UserInactiveError2(`${where}: the user is inactive`, {
1698
+ userId: record.id,
1699
+ userType: type.name
1700
+ });
1701
+ }
1702
+ const issued = isActive(record.secondFactor) ? await byApp(context, record, where) : await byEmail(context, type, record, where);
1703
+ await context.store.tokens.spendUserTokens(record.id, "stepUp", context.clock.now(), hashSecret(issued.challenge));
1704
+ return issued;
1705
+ }
1706
+ async function byApp(context, record, where) {
1707
+ requireSettings(context, where, "the user's second factor is active");
1708
+ const { secret, expiresAt } = await issueOneTime(context, {
1709
+ kind: "stepUp",
1710
+ userId: record.id,
1711
+ address: "",
1712
+ ttlMs: context.config.tokenTtlMs.stepUp
1713
+ });
1714
+ return {
1715
+ via: "secondFactor",
1716
+ challenge: secret,
1717
+ expiresAt,
1718
+ user: toUser(record)
1719
+ };
1720
+ }
1721
+ async function byEmail(context, type, record, where) {
1722
+ const email = emailOf(type, record.fields);
1723
+ if (email === null) {
1724
+ throw new NotFoundError2(`${where}: the user has no e-mail`, {
1725
+ userId: record.id,
1726
+ userType: type.name,
1727
+ operation: where
1728
+ });
1729
+ }
1730
+ const { secret, code, expiresAt } = await issueCode(context, {
1731
+ kind: "stepUp",
1732
+ userId: record.id,
1733
+ address: email,
1734
+ ttlMs: context.config.tokenTtlMs.stepUp
1735
+ });
1736
+ return {
1737
+ via: "email",
1738
+ code,
1739
+ challenge: secret,
1740
+ email,
1741
+ expiresAt,
1742
+ user: toUser(record)
1743
+ };
1744
+ }
1745
+
1746
+ // src/auth/step-up/flows.ts
1747
+ function stepUpFlows(context, type, at) {
1748
+ return {
1749
+ async request(user) {
1750
+ return requestStepUp(context, type, user, at("stepUp.request"));
1751
+ },
1752
+ async confirm(request, challenge, code) {
1753
+ return confirmStepUp(context, type, request, String(challenge), String(code), at("stepUp.confirm"));
1754
+ }
1755
+ };
1756
+ }
1757
+ async function confirmStepUp(context, type, request, secret, code, where) {
1758
+ const { token, attemptsLeft } = await countCodeAttempt(context, secret, "stepUp", where, type.name);
1759
+ const { session, user } = await openStepUp(context, type, request, token, secret, where);
1760
+ const counted = { token, secret, attemptsLeft };
1761
+ if (token.codeHash === null) {
1762
+ await checkAppCode(context, type, user, counted, code, where);
1763
+ } else {
1764
+ await checkEmailedCode(context, type, user, counted, code, where);
1765
+ }
1766
+ const confirmed = await context.store.sessions.reauthenticateSession(session.id, context.clock.now());
1767
+ if (confirmed === null) {
1768
+ throw new TokenError2("TOKEN_UNKNOWN", `${where}: the session was signed out while the code was checked`, { operation: where });
1769
+ }
1770
+ return toSession(confirmed);
1771
+ }
1772
+
1619
1773
  // src/auth/users/by-login.ts
1620
1774
  var byLogin = async (context, type, login) => isStorable(login) ? context.store.users.findUserByLogin(type.name, login) : null;
1621
1775
 
@@ -1829,6 +1983,7 @@ function typeApi(context, type) {
1829
1983
  ...passwordFlows(context, type, at, secondFactor.finish),
1830
1984
  secondFactor: secondFactor.api,
1831
1985
  signInCode,
1986
+ stepUp: stepUpFlows(context, type, at),
1832
1987
  ...emailFlows(context, type, at)
1833
1988
  };
1834
1989
  }
@@ -1865,6 +2020,14 @@ function janus(config) {
1865
2020
  const surface = resolved.single ? { ...shared, ...types.user } : { ...shared, ...types };
1866
2021
  return surface;
1867
2022
  }
2023
+ // src/auth/sessions/fresh.ts
2024
+ function assertFresh(session, maxAge, clock = systemClock) {
2025
+ const maxAgeMs = parseDuration(maxAge, "assertFresh: maxAge");
2026
+ const age = clock.now().getTime() - session.authenticatedAt.getTime();
2027
+ if (age >= maxAgeMs) {
2028
+ throw new StepUpRequiredError2("assertFresh: the session proved who it is longer ago than maxAge — confirm with a step-up", { userId: session.userId });
2029
+ }
2030
+ }
1868
2031
  export {
1869
2032
  CredentialError2 as CredentialError,
1870
2033
  DEFAULT_PAGE_SIZE2 as DEFAULT_PAGE_SIZE,
@@ -1874,12 +2037,14 @@ export {
1874
2037
  NotFoundError2 as NotFoundError,
1875
2038
  PermissionDepthError2 as PermissionDepthError,
1876
2039
  SecondFactorError2 as SecondFactorError,
2040
+ StepUpRequiredError2 as StepUpRequiredError,
1877
2041
  StoreConflict2 as StoreConflict,
1878
2042
  StoreFailure2 as StoreFailure,
1879
2043
  TokenError2 as TokenError,
1880
2044
  UnsupportedError2 as UnsupportedError,
1881
2045
  UserInactiveError2 as UserInactiveError,
1882
2046
  UserInvalidError2 as UserInvalidError,
2047
+ assertFresh,
1883
2048
  assertStores,
1884
2049
  bunHasher,
1885
2050
  createMemoryStores2 as createMemoryStores,
@@ -1905,5 +2070,5 @@ export {
1905
2070
  systemClock
1906
2071
  };
1907
2072
 
1908
- //# debugId=15D11ACC3B49E27464756E2164756E21
2073
+ //# debugId=301D5A6A554F106664756E2164756E21
1909
2074
  //# sourceMappingURL=index.js.map