@nxgt/janus 0.14.0 → 0.16.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 (52) hide show
  1. package/README.md +109 -20
  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/email-sign-in.d.ts +45 -0
  7. package/dist/auth/email-sign-in.d.ts.map +1 -0
  8. package/dist/auth/events.d.ts +10 -4
  9. package/dist/auth/events.d.ts.map +1 -1
  10. package/dist/auth/first-proof.d.ts +37 -0
  11. package/dist/auth/first-proof.d.ts.map +1 -0
  12. package/dist/auth/index.d.ts +1 -1
  13. package/dist/auth/index.d.ts.map +1 -1
  14. package/dist/auth/magic-link.d.ts +22 -0
  15. package/dist/auth/magic-link.d.ts.map +1 -0
  16. package/dist/auth/port/types/tokens.d.ts +6 -3
  17. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  18. package/dist/auth/sign-in-code.d.ts +1 -3
  19. package/dist/auth/sign-in-code.d.ts.map +1 -1
  20. package/dist/auth/types/email-flows.d.ts +7 -4
  21. package/dist/auth/types/email-flows.d.ts.map +1 -1
  22. package/dist/auth/types/index.d.ts +1 -0
  23. package/dist/auth/types/index.d.ts.map +1 -1
  24. package/dist/auth/types/janus.d.ts +8 -1
  25. package/dist/auth/types/janus.d.ts.map +1 -1
  26. package/dist/auth/types/magic-link.d.ts +50 -0
  27. package/dist/auth/types/magic-link.d.ts.map +1 -0
  28. package/dist/auth/users/any-type-api.d.ts +2 -2
  29. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  30. package/dist/auth/users/type-api.d.ts +2 -1
  31. package/dist/auth/users/type-api.d.ts.map +1 -1
  32. package/dist/conformance/cases/tokens/kinds.d.ts +4 -1
  33. package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -1
  34. package/dist/conformance/index.js +23 -1
  35. package/dist/conformance/index.js.map +3 -3
  36. package/dist/index.js +173 -88
  37. package/dist/index.js.map +12 -9
  38. package/docs/README.md +2 -1
  39. package/docs/guide/adapters.md +11 -7
  40. package/docs/guide/email-flows.md +14 -8
  41. package/docs/guide/errors.md +2 -2
  42. package/docs/guide/events.md +5 -5
  43. package/docs/guide/magic-link.md +442 -0
  44. package/docs/guide/passwords.md +5 -4
  45. package/docs/guide/second-factor.md +28 -2
  46. package/docs/guide/sessions.md +4 -1
  47. package/docs/guide/sign-in-code.md +20 -7
  48. package/docs/guide/users.md +10 -6
  49. package/docs/guide/vocabulary.md +6 -5
  50. package/docs/roadmap.md +18 -12
  51. package/docs/troubleshooting.md +178 -12
  52. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -407,6 +407,7 @@ function resolveTokenTtls(config, where) {
407
407
  verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
408
408
  resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`),
409
409
  signInCode: parseDuration(config.tokens?.signInCode ?? "10m", `${where}: tokens.signInCode`),
410
+ magicLink: parseDuration(config.tokens?.magicLink ?? "15m", `${where}: tokens.magicLink`),
410
411
  stepUp: parseDuration(config.tokens?.stepUp ?? "10m", `${where}: tokens.stepUp`)
411
412
  };
412
413
  }
@@ -1199,78 +1200,8 @@ function emailFlows(context, type, at) {
1199
1200
  resetPassword: resetPasswordFlows(context, type, at)
1200
1201
  };
1201
1202
  }
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
-
1272
1203
  // src/auth/totp.ts
1273
- import { createHmac as createHmac2, randomBytes as randomBytes4 } from "node:crypto";
1204
+ import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
1274
1205
  var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
1275
1206
  var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
1276
1207
  function mintTotpSecret() {
@@ -1313,7 +1244,7 @@ var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
1313
1244
  function codeAt(secret, step) {
1314
1245
  const counter = Buffer.alloc(8);
1315
1246
  counter.writeBigUInt64BE(BigInt(step));
1316
- const digest = createHmac2("sha1", secret).update(counter).digest();
1247
+ const digest = createHmac("sha1", secret).update(counter).digest();
1317
1248
  const offset = digest[19] & 15;
1318
1249
  const binary = digest.readUInt32BE(offset) & 2147483647;
1319
1250
  return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
@@ -1371,6 +1302,165 @@ function acceptCode(configured, record, factor, code, now, where) {
1371
1302
  };
1372
1303
  }
1373
1304
 
1305
+ // src/auth/first-proof.ts
1306
+ async function proveFirstEmail(context, type, user, where) {
1307
+ await endWhatTheSquatterOpened(context, user.id);
1308
+ let dropped = false;
1309
+ let disabled = false;
1310
+ const proved = await writeUser(context, user.id, type, { ifVersion: user.version }, where, (record, now) => {
1311
+ dropped = record.password !== null;
1312
+ disabled = isActive(record.secondFactor);
1313
+ return {
1314
+ emailVerifiedAt: now,
1315
+ ...dropped ? { password: null } : {},
1316
+ ...record.secondFactor === null ? {} : { secondFactor: null }
1317
+ };
1318
+ });
1319
+ try {
1320
+ await endWhatTheSquatterOpened(context, proved.id);
1321
+ } finally {
1322
+ await emit(context, "user.emailVerified", proved, proved.updatedAt);
1323
+ if (dropped) {
1324
+ await emit(context, "user.passwordChanged", proved, proved.updatedAt);
1325
+ }
1326
+ if (disabled) {
1327
+ await emit(context, "user.secondFactorDisabled", proved, proved.updatedAt);
1328
+ }
1329
+ }
1330
+ return proved;
1331
+ }
1332
+ async function endWhatTheSquatterOpened(context, userId) {
1333
+ await context.store.sessions.revokeUserSessions(userId, context.clock.now());
1334
+ await endWhatThePasswordOpened(context, userId);
1335
+ }
1336
+
1337
+ // src/auth/email-sign-in.ts
1338
+ async function signInHolder(context, type, email) {
1339
+ const record = await holderOfEmail(context, type, email);
1340
+ return record === null || !record.active ? null : record;
1341
+ }
1342
+ async function keepOnlyLatest(context, userId, kind, secret) {
1343
+ await context.store.tokens.spendUserTokens(userId, kind, context.clock.now(), hashSecret(secret));
1344
+ }
1345
+ async function finishEmailSignIn(context, type, finish, signIn) {
1346
+ const { user, token, where, noun } = signIn;
1347
+ refuseStale(type, user, token, where, noun);
1348
+ if (!user.active) {
1349
+ throw new UserInactiveError2(`${where}: the user is inactive`, {
1350
+ userId: user.id,
1351
+ userType: type.name
1352
+ });
1353
+ }
1354
+ if (user.emailVerifiedAt !== null)
1355
+ return finish(user, where);
1356
+ return finish(await proveFirstEmail(context, type, user, where), where);
1357
+ }
1358
+
1359
+ // src/auth/magic-link.ts
1360
+ function magicLinkFlows(context, type, at, finish) {
1361
+ return {
1362
+ async request(email) {
1363
+ return requestLink(context, type, String(email));
1364
+ },
1365
+ async confirm(token) {
1366
+ return confirmLink(context, type, finish, String(token), at("magicLink.confirm"));
1367
+ }
1368
+ };
1369
+ }
1370
+ async function requestLink(context, type, email) {
1371
+ const record = await signInHolder(context, type, email);
1372
+ if (record === null)
1373
+ return null;
1374
+ const address = String(record.fields[type.email]);
1375
+ const { secret, expiresAt } = await issueOneTime(context, {
1376
+ kind: "magicLink",
1377
+ userId: record.id,
1378
+ address,
1379
+ ttlMs: context.config.tokenTtlMs.magicLink
1380
+ });
1381
+ await keepOnlyLatest(context, record.id, "magicLink", secret);
1382
+ return { token: secret, email: address, expiresAt, user: toUser(record) };
1383
+ }
1384
+ async function confirmLink(context, type, finish, secret, where) {
1385
+ const { token, user } = await redeemEmailToken(context, type, "magicLink", secret, where);
1386
+ return finishEmailSignIn(context, type, finish, {
1387
+ user,
1388
+ token,
1389
+ where,
1390
+ noun: "token"
1391
+ });
1392
+ }
1393
+
1394
+ // src/auth/derived-keys.ts
1395
+ import { createHmac as createHmac2, hkdfSync } from "node:crypto";
1396
+ var derived = new WeakMap;
1397
+ function derivedKey(sealer, purpose, keyId = sealer.sealWith) {
1398
+ let keys = derived.get(sealer);
1399
+ if (keys === undefined) {
1400
+ keys = new Map;
1401
+ derived.set(sealer, keys);
1402
+ }
1403
+ const name = `${purpose}\x00${keyId}`;
1404
+ let key = keys.get(name);
1405
+ if (key === undefined) {
1406
+ const secret = sealer.keys.get(keyId);
1407
+ if (secret === undefined)
1408
+ return;
1409
+ key = Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), purpose, 32));
1410
+ keys.set(name, key);
1411
+ }
1412
+ return key;
1413
+ }
1414
+ function keyedHash(key, parts) {
1415
+ return createHmac2("sha256", key).update(parts.join("\x00")).digest();
1416
+ }
1417
+
1418
+ // src/auth/sign-in-attempts.ts
1419
+ var PURPOSE = "janus/sign-in/attempts/v1";
1420
+ var UNSEALED = Buffer.from(PURPOSE);
1421
+ function counterKey(context) {
1422
+ const sealer = context.config.secondFactor?.sealer;
1423
+ return sealer === undefined ? UNSEALED : derivedKey(sealer, PURPOSE);
1424
+ }
1425
+ var keyed = (key, parts) => keyedHash(key, parts).toString("base64url");
1426
+ async function countLogin(context, type, login, where) {
1427
+ const throttle = context.config.signInThrottle;
1428
+ if (throttle === null)
1429
+ return null;
1430
+ const key = counterKey(context);
1431
+ const loginKey = keyed(key, [type.name, login]);
1432
+ return countInWindow(context, {
1433
+ windowMs: throttle.windowMs,
1434
+ spent: "restart",
1435
+ userId: loginKey,
1436
+ linkHash: (window, link) => hashSecret(keyed(key, [loginKey, String(window), String(link)])),
1437
+ vanished: () => new StoreFailure2(`${where}: the store dropped the attempts it had just stored`, { slot: "tokens", operation: "countAttempt" })
1438
+ });
1439
+ }
1440
+ async function countSignInAttempt(context, type, login, where) {
1441
+ const counted = await countLogin(context, type, login, where);
1442
+ const limit = context.config.signInThrottle?.attempts;
1443
+ if (counted === null || limit === undefined || counted.attempts <= limit) {
1444
+ return;
1445
+ }
1446
+ const now = context.clock.now().getTime();
1447
+ throw new CredentialError2("CREDENTIALS_INVALID", `${where}: too many passwords tried at this login — wait for the next window`, {
1448
+ reason: "throttled",
1449
+ userType: type.name,
1450
+ retryAfter: Math.max(1, Math.ceil((counted.endsAt - now) / 1000))
1451
+ });
1452
+ }
1453
+ async function restartSignInCount(context, type, record, where) {
1454
+ const rule = type.password;
1455
+ const login = rule === null ? undefined : record.fields[rule.login];
1456
+ if (rule === null || typeof login !== "string")
1457
+ return;
1458
+ const counted = await countLogin(context, type, rule.normalize(login), where);
1459
+ if (counted?.link == null)
1460
+ return;
1461
+ await context.store.tokens.consumeToken(counted.link, "secondFactor", context.clock.now());
1462
+ }
1463
+
1374
1464
  // src/auth/second-factor/redeem.ts
1375
1465
  var spendChallenge = (context, challenge, where) => spendOneTime(context, challenge, "secondFactor", where, "challenge");
1376
1466
  async function openChallenge(context, type, challenge, where) {
@@ -1710,8 +1800,8 @@ function signInCodeFlows(context, type, at, finish) {
1710
1800
  };
1711
1801
  }
1712
1802
  async function requestCode(context, type, email) {
1713
- const record = await holderOfEmail(context, type, email);
1714
- if (record === null || !record.active)
1803
+ const record = await signInHolder(context, type, email);
1804
+ if (record === null)
1715
1805
  return null;
1716
1806
  const { secret, code, expiresAt } = await issueCode(context, {
1717
1807
  kind: "signInCode",
@@ -1719,7 +1809,7 @@ async function requestCode(context, type, email) {
1719
1809
  address: String(record.fields[type.email]),
1720
1810
  ttlMs: context.config.tokenTtlMs.signInCode
1721
1811
  });
1722
- await context.store.tokens.spendUserTokens(record.id, "signInCode", context.clock.now(), hashSecret(secret));
1812
+ await keepOnlyLatest(context, record.id, "signInCode", secret);
1723
1813
  return {
1724
1814
  code,
1725
1815
  challenge: secret,
@@ -1743,18 +1833,12 @@ async function confirmCode(context, type, finish, challenge, code, at) {
1743
1833
  throw codeInvalid(where, user.id, type.name, attemptsLeft);
1744
1834
  }
1745
1835
  await spendOneTime(context, secret, "signInCode", where, "challenge");
1746
- refuseStale(type, user, token, where, "code");
1747
- if (!user.active) {
1748
- throw new UserInactiveError2(`${where}: the user is inactive`, {
1749
- userId: user.id,
1750
- userType: type.name
1751
- });
1752
- }
1753
- if (user.emailVerifiedAt !== null)
1754
- return finish(user, where);
1755
- const proved = await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now }));
1756
- await emit(context, "user.emailVerified", proved, proved.updatedAt);
1757
- return finish(proved, where);
1836
+ return finishEmailSignIn(context, type, finish, {
1837
+ user,
1838
+ token,
1839
+ where,
1840
+ noun: "code"
1841
+ });
1758
1842
  }
1759
1843
 
1760
1844
  // src/auth/step-up/codes.ts
@@ -2128,6 +2212,7 @@ function typeApi(context, type) {
2128
2212
  ...passwordFlows(context, type, at, secondFactor.finish),
2129
2213
  secondFactor: secondFactor.api,
2130
2214
  signInCode,
2215
+ magicLink: magicLinkFlows(context, type, at, secondFactor.finish),
2131
2216
  stepUp: stepUpFlows(context, type, at),
2132
2217
  ...emailFlows(context, type, at)
2133
2218
  };
@@ -2215,5 +2300,5 @@ export {
2215
2300
  systemClock
2216
2301
  };
2217
2302
 
2218
- //# debugId=7CE655305FF58FDF64756E2164756E21
2303
+ //# debugId=BC1254710D035B0264756E2164756E21
2219
2304
  //# sourceMappingURL=index.js.map