@nxgt/janus 0.6.0 → 0.8.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 (44) hide show
  1. package/README.md +107 -17
  2. package/dist/auth/config.d.ts +8 -0
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts +10 -1
  5. package/dist/auth/context.d.ts.map +1 -1
  6. package/dist/auth/email-flows.d.ts +11 -0
  7. package/dist/auth/email-flows.d.ts.map +1 -0
  8. package/dist/auth/events.d.ts +52 -0
  9. package/dist/auth/events.d.ts.map +1 -0
  10. package/dist/auth/index.d.ts +1 -0
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/janus.d.ts.map +1 -1
  13. package/dist/auth/one-time.d.ts +7 -0
  14. package/dist/auth/one-time.d.ts.map +1 -1
  15. package/dist/auth/password-written.d.ts +24 -0
  16. package/dist/auth/password-written.d.ts.map +1 -0
  17. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  18. package/dist/auth/port/types.d.ts +23 -1
  19. package/dist/auth/port/types.d.ts.map +1 -1
  20. package/dist/auth/second-factor/challenge.d.ts.map +1 -1
  21. package/dist/auth/sign-in-code.d.ts +6 -3
  22. package/dist/auth/sign-in-code.d.ts.map +1 -1
  23. package/dist/auth/types.d.ts +6 -0
  24. package/dist/auth/types.d.ts.map +1 -1
  25. package/dist/auth/users.d.ts.map +1 -1
  26. package/dist/chunks/{index-gbwn2tts.js → index-c0jajeda.js} +12 -2
  27. package/dist/chunks/{index-gbwn2tts.js.map → index-c0jajeda.js.map} +3 -3
  28. package/dist/conformance/cases/outage.d.ts.map +1 -1
  29. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  30. package/dist/conformance/index.js +73 -2
  31. package/dist/conformance/index.js.map +4 -4
  32. package/dist/index.js +207 -95
  33. package/dist/index.js.map +14 -11
  34. package/docs/README.md +2 -1
  35. package/docs/guide/adapters.md +56 -3
  36. package/docs/guide/email-flows.md +11 -1
  37. package/docs/guide/events.md +199 -0
  38. package/docs/guide/second-factor.md +27 -2
  39. package/docs/guide/sign-in-code.md +28 -9
  40. package/docs/guide/users.md +8 -4
  41. package/docs/guide/vocabulary.md +3 -0
  42. package/docs/roadmap.md +25 -13
  43. package/docs/troubleshooting.md +89 -26
  44. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  isId2,
12
12
  mintedAt2,
13
13
  createMemoryStores2
14
- } from "./chunks/index-gbwn2tts.js";
14
+ } from "./chunks/index-c0jajeda.js";
15
15
  import {
16
16
  JanusError2,
17
17
  StoreFailure2,
@@ -389,7 +389,7 @@ async function unlessVersionConflict(write) {
389
389
  }
390
390
 
391
391
  // src/auth/context.ts
392
- function createContext(config, store, relations, capabilities, clock, hasher, verifiers) {
392
+ function createContext(config, store, relations, capabilities, clock, hasher, verifiers, events = null) {
393
393
  let dummy = null;
394
394
  return {
395
395
  config,
@@ -399,6 +399,7 @@ function createContext(config, store, relations, capabilities, clock, hasher, ve
399
399
  clock,
400
400
  hasher,
401
401
  verifiers,
402
+ events,
402
403
  dummyHash: () => {
403
404
  if (hasher === null) {
404
405
  throw new TypeError("janus: no hasher to compare a dummy hash with");
@@ -514,6 +515,12 @@ function loginsOf(type, fields, where) {
514
515
  logins.add(normalizeEmail(email));
515
516
  return [...logins];
516
517
  }
518
+ function passwordRule(type, where) {
519
+ if (type.password === null) {
520
+ throw new TypeError(`${where}: the ${type.name} type does not sign in with a password — add password: { login } to it`);
521
+ }
522
+ return type.password;
523
+ }
517
524
  function checkPassword(type, password, where) {
518
525
  const minLength = type.password?.minLength ?? 8;
519
526
  if (typeof password !== "string" || password.length < minLength) {
@@ -589,6 +596,33 @@ async function holderOfEmail(context, type, email) {
589
596
  return held !== null && normalizeEmail(held) === wanted ? record : null;
590
597
  }
591
598
 
599
+ // src/auth/events.ts
600
+ function resolveEvents(events, where) {
601
+ if (events === undefined)
602
+ return null;
603
+ if (typeof events !== "function") {
604
+ throw new TypeError(`${where}: events must be a function that takes a user event — webhooks({ … }) from @nxgt/janus-webhooks, or your own`);
605
+ }
606
+ return events;
607
+ }
608
+ async function emit(context, type, user, occurredAt) {
609
+ const listener = context.events;
610
+ if (listener === null)
611
+ return;
612
+ const event = Object.freeze({
613
+ id: mintId2(occurredAt.getTime()),
614
+ type,
615
+ occurredAt: new Date(occurredAt.getTime()),
616
+ userId: user.id,
617
+ userType: user.type
618
+ });
619
+ try {
620
+ await listener(event);
621
+ } catch (failure) {
622
+ process.emitWarning(`janus: the events listener failed on ${type} ${event.id} for user ${user.id}: ${failure instanceof Error ? failure.name : typeof failure}`, { code: "JANUS_EVENT_FAILED" });
623
+ }
624
+ }
625
+
592
626
  // src/auth/port/assert-stores.ts
593
627
  var REQUIRED = {
594
628
  users: [
@@ -607,7 +641,13 @@ var REQUIRED = {
607
641
  "revokeUserSessions",
608
642
  "deleteUserSessions"
609
643
  ],
610
- tokens: ["insertToken", "consumeToken", "countAttempt", "deleteUserTokens"]
644
+ tokens: [
645
+ "insertToken",
646
+ "consumeToken",
647
+ "countAttempt",
648
+ "spendUserTokens",
649
+ "deleteUserTokens"
650
+ ]
611
651
  };
612
652
  function assertStores(stores, where) {
613
653
  if (typeof stores !== "object" || stores === null) {
@@ -833,6 +873,12 @@ function refuseUnusable(token, now, where, noun) {
833
873
  var unknownOneTime = (where, noun) => new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
834
874
  operation: where
835
875
  });
876
+ async function unknownChallenge(context, token, secret, where) {
877
+ if (token.attempts === CODE_ATTEMPTS) {
878
+ await burnOneTime(context, secret, token.kind);
879
+ }
880
+ return unknownOneTime(where, "challenge");
881
+ }
836
882
  async function spendOneTime(context, secret, kind, where, noun) {
837
883
  const now = context.clock.now();
838
884
  const token = await context.store.tokens.consumeToken(hashSecret(secret), kind, now);
@@ -875,6 +921,113 @@ async function insertOneTime(context, token, code) {
875
921
  return { secret, expiresAt };
876
922
  }
877
923
 
924
+ // src/auth/password-written.ts
925
+ async function endSignInsWaiting(context, userId) {
926
+ await context.store.tokens.spendUserTokens(userId, "secondFactor", context.clock.now());
927
+ }
928
+ async function heldByPassword(context, type, verified, password, result, where) {
929
+ const now = await findRecord(context, verified.id, type.name);
930
+ const kept = now !== null && now.password !== null && (now.password.hash === verified.password?.hash || await passwordMatches(context, now, password, where));
931
+ if (kept)
932
+ return true;
933
+ if (result.status === "signedIn") {
934
+ await context.store.sessions.revokeSession(result.session.id, context.clock.now());
935
+ } else {
936
+ await burnOneTime(context, result.challenge, "secondFactor");
937
+ }
938
+ return false;
939
+ }
940
+
941
+ // src/auth/email-flows.ts
942
+ function emailFlows(context, type, at) {
943
+ const { store, clock } = context;
944
+ const redeem = async (kind, secret, where) => {
945
+ const token = await spendOneTime(context, secret, kind, where, "token");
946
+ const user = await findRecord(context, token.userId, type.name);
947
+ if (user === null)
948
+ throw unknownOneTime(where, "token");
949
+ refuseStale(type, user, token, where, "token");
950
+ return { token, user };
951
+ };
952
+ const issue = async (kind, user, where) => {
953
+ const email = emailOf(type, user.fields);
954
+ if (email === null) {
955
+ throw new NotFoundError2(`${where}: the user has no e-mail`, {
956
+ userId: user.id,
957
+ userType: type.name,
958
+ operation: where
959
+ });
960
+ }
961
+ const { secret, expiresAt } = await issueOneTime(context, {
962
+ kind,
963
+ userId: user.id,
964
+ address: email,
965
+ ttlMs: context.config.tokenTtlMs[kind]
966
+ });
967
+ return { token: secret, email, expiresAt };
968
+ };
969
+ return {
970
+ verifyEmail: {
971
+ async send(user) {
972
+ const where = at("verifyEmail.send");
973
+ const record = await getRecord(context, idOf(user), type.name, where);
974
+ return issue("verifyEmail", record, where);
975
+ },
976
+ async confirm(secret) {
977
+ const where = at("verifyEmail.confirm");
978
+ const { token, user } = await redeem("verifyEmail", secret, where);
979
+ let newlyVerified = false;
980
+ const written = await writeUser(context, user.id, type, undefined, where, (record, now) => {
981
+ refuseStale(type, record, token, where, "token");
982
+ newlyVerified = record.emailVerifiedAt === null;
983
+ return { emailVerifiedAt: now };
984
+ });
985
+ if (newlyVerified) {
986
+ await emit(context, "user.emailVerified", written, written.updatedAt);
987
+ }
988
+ return toUser(written);
989
+ }
990
+ },
991
+ resetPassword: {
992
+ async request(email) {
993
+ const where = at("resetPassword.request");
994
+ passwordRule(type, where);
995
+ const record = await holderOfEmail(context, type, String(email));
996
+ if (record === null)
997
+ return null;
998
+ const issued = await issue("resetPassword", record, where);
999
+ return { ...issued, user: toUser(record) };
1000
+ },
1001
+ async confirm(secret, password) {
1002
+ const where = at("resetPassword.confirm");
1003
+ passwordRule(type, where);
1004
+ checkPassword(type, password, where);
1005
+ const hash = await requireHasher(context, where).hash(password);
1006
+ const { token, user } = await redeem("resetPassword", secret, where);
1007
+ let newlyVerified = false;
1008
+ const written = await writeUser(context, user.id, type, undefined, where, (record, now) => {
1009
+ refuseStale(type, record, token, where, "token");
1010
+ newlyVerified = record.emailVerifiedAt === null;
1011
+ return {
1012
+ password: { hash, updatedAt: now },
1013
+ ...record.emailVerifiedAt === null ? { emailVerifiedAt: now } : {}
1014
+ };
1015
+ });
1016
+ try {
1017
+ await store.sessions.revokeUserSessions(written.id, clock.now());
1018
+ await endSignInsWaiting(context, written.id);
1019
+ } finally {
1020
+ await emit(context, "user.passwordReset", written, written.updatedAt);
1021
+ if (newlyVerified) {
1022
+ await emit(context, "user.emailVerified", written, written.updatedAt);
1023
+ }
1024
+ }
1025
+ return toUser(written);
1026
+ }
1027
+ }
1028
+ };
1029
+ }
1030
+
878
1031
  // src/auth/totp.ts
879
1032
  import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
880
1033
  var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
@@ -990,7 +1143,12 @@ function challengeFlows(context, type, at) {
990
1143
  address: "",
991
1144
  ttlMs: configured.challengeTtlMs
992
1145
  });
993
- return { status: "secondFactor", challenge: secret, expiresAt };
1146
+ return {
1147
+ status: "secondFactor",
1148
+ challenge: secret,
1149
+ expiresAt,
1150
+ userId: record.id
1151
+ };
994
1152
  },
995
1153
  async confirm(challenge, code) {
996
1154
  const where = at("secondFactor.confirm");
@@ -998,8 +1156,9 @@ function challengeFlows(context, type, at) {
998
1156
  const secret = String(challenge);
999
1157
  const { token, attemptsLeft } = await countCodeAttempt(context, secret, "secondFactor", where, type.name);
1000
1158
  const record = await findRecord(context, token.userId, type.name);
1001
- if (record === null)
1002
- throw unknownOneTime(where, "challenge");
1159
+ if (record === null) {
1160
+ throw await unknownChallenge(context, token, secret, where);
1161
+ }
1003
1162
  if (!record.active) {
1004
1163
  await spend(secret, where);
1005
1164
  throw new UserInactiveError2(`${where}: the user is inactive`, {
@@ -1116,6 +1275,7 @@ function signInCodeFlows(context, type, at, finish) {
1116
1275
  address: String(record.fields[type.email]),
1117
1276
  ttlMs: context.config.tokenTtlMs.signInCode
1118
1277
  });
1278
+ await context.store.tokens.spendUserTokens(record.id, "signInCode", context.clock.now(), hashSecret(secret));
1119
1279
  return {
1120
1280
  code,
1121
1281
  challenge: secret,
@@ -1129,8 +1289,9 @@ function signInCodeFlows(context, type, at, finish) {
1129
1289
  const secret = String(challenge);
1130
1290
  const { token, attemptsLeft } = await countCodeAttempt(context, secret, "signInCode", where, type.name);
1131
1291
  const user = await findRecord(context, token.userId, type.name);
1132
- if (user === null)
1133
- throw unknownOneTime(where, "challenge");
1292
+ if (user === null) {
1293
+ throw await unknownChallenge(context, token, secret, where);
1294
+ }
1134
1295
  if (!codeMatches(token, secret, String(code))) {
1135
1296
  if (token.attempts === CODE_ATTEMPTS) {
1136
1297
  await burnOneTime(context, secret, "signInCode");
@@ -1145,7 +1306,10 @@ function signInCodeFlows(context, type, at, finish) {
1145
1306
  userType: type.name
1146
1307
  });
1147
1308
  }
1148
- const proved = user.emailVerifiedAt === null ? await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now })) : user;
1309
+ if (user.emailVerifiedAt !== null)
1310
+ return finish(user, where);
1311
+ const proved = await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now }));
1312
+ await emit(context, "user.emailVerified", proved, proved.updatedAt);
1149
1313
  return finish(proved, where);
1150
1314
  }
1151
1315
  };
@@ -1170,7 +1334,7 @@ function typeApi(context, type) {
1170
1334
  updatedAt: now
1171
1335
  };
1172
1336
  }
1173
- return store.users.insertUser({
1337
+ const inserted = await store.users.insertUser({
1174
1338
  id: mintId2(now.getTime()),
1175
1339
  type: type.name,
1176
1340
  schemaVersion: type.schemaVersion,
@@ -1184,37 +1348,8 @@ function typeApi(context, type) {
1184
1348
  createdAt: now,
1185
1349
  updatedAt: now
1186
1350
  });
1187
- };
1188
- const redeem = async (kind, secret, where) => {
1189
- const token = await spendOneTime(context, secret, kind, where, "token");
1190
- const user = await findRecord(context, token.userId, type.name);
1191
- if (user === null)
1192
- throw unknownOneTime(where, "token");
1193
- refuseStale(type, user, token, where, "token");
1194
- return { token, user };
1195
- };
1196
- const issue = async (kind, user, where) => {
1197
- const email = emailOf(type, user.fields);
1198
- if (email === null) {
1199
- throw new NotFoundError2(`${where}: the user has no e-mail`, {
1200
- userId: user.id,
1201
- userType: type.name,
1202
- operation: where
1203
- });
1204
- }
1205
- const { secret, expiresAt } = await issueOneTime(context, {
1206
- kind,
1207
- userId: user.id,
1208
- address: email,
1209
- ttlMs: context.config.tokenTtlMs[kind]
1210
- });
1211
- return { token: secret, email, expiresAt };
1212
- };
1213
- const passwordRule = (where) => {
1214
- if (type.password === null) {
1215
- throw new TypeError(`${where}: the ${type.name} type does not sign in with a password — add password: { login } to it`);
1216
- }
1217
- return type.password;
1351
+ await emit(context, "user.created", inserted, inserted.createdAt);
1352
+ return inserted;
1218
1353
  };
1219
1354
  return {
1220
1355
  async create(input) {
@@ -1267,15 +1402,22 @@ function typeApi(context, type) {
1267
1402
  const record = await store.users.findUser(id);
1268
1403
  if (record !== null && record.type !== type.name)
1269
1404
  return false;
1405
+ const deletedAt = clock.now();
1270
1406
  const deleted = record !== null && await store.users.deleteUser(id);
1271
- await store.sessions.deleteUserSessions(id);
1272
- await store.tokens.deleteUserTokens(id);
1273
- await context.relations?.deleteEntity({ type: type.name, id });
1407
+ try {
1408
+ await store.sessions.deleteUserSessions(id);
1409
+ await store.tokens.deleteUserTokens(id);
1410
+ await context.relations?.deleteEntity({ type: type.name, id });
1411
+ } finally {
1412
+ if (deleted) {
1413
+ await emit(context, "user.deleted", { id, type: type.name }, deletedAt);
1414
+ }
1415
+ }
1274
1416
  return deleted;
1275
1417
  },
1276
1418
  async signUp(input) {
1277
1419
  const where = at("signUp");
1278
- passwordRule(where);
1420
+ passwordRule(type, where);
1279
1421
  if (input?.password === undefined) {
1280
1422
  checkPassword(type, undefined, where);
1281
1423
  }
@@ -1284,7 +1426,7 @@ function typeApi(context, type) {
1284
1426
  },
1285
1427
  async signIn(input) {
1286
1428
  const where = at("signIn");
1287
- const rule = passwordRule(where);
1429
+ const rule = passwordRule(type, where);
1288
1430
  const hasher = requireHasher(context, where);
1289
1431
  const login = input?.[rule.login];
1290
1432
  const password = input?.password;
@@ -1303,28 +1445,33 @@ function typeApi(context, type) {
1303
1445
  userType: type.name
1304
1446
  });
1305
1447
  }
1306
- return secondFactor.finish(await rehashed(context, record, String(password)), where);
1448
+ const verified = await rehashed(context, record, String(password));
1449
+ const result = await secondFactor.finish(verified, where);
1450
+ if (!await heldByPassword(context, type, verified, String(password), result, where)) {
1451
+ throw refuse("wrongPassword");
1452
+ }
1453
+ return result;
1307
1454
  },
1308
1455
  async findByLogin(login) {
1309
- const rule = passwordRule(at("findByLogin"));
1456
+ const rule = passwordRule(type, at("findByLogin"));
1310
1457
  const record = await byLogin(rule.normalize(login));
1311
1458
  return record === null ? null : toUser(record);
1312
1459
  },
1313
1460
  async setPassword(user, password, options) {
1314
1461
  const where = at("setPassword");
1315
- passwordRule(where);
1462
+ passwordRule(type, where);
1316
1463
  checkPassword(type, password, where);
1317
1464
  const hash = await requireHasher(context, where).hash(password);
1318
- return toUser(await writeUser(context, user, type, options, where, (_, now) => ({
1319
- password: { hash, updatedAt: now }
1320
- })));
1465
+ const written = await writeUser(context, user, type, options, where, (_, now) => ({ password: { hash, updatedAt: now } }));
1466
+ await endSignInsWaiting(context, written.id);
1467
+ return toUser(written);
1321
1468
  },
1322
1469
  async changePassword(user, change, options) {
1323
1470
  const where = at("changePassword");
1324
- passwordRule(where);
1471
+ passwordRule(type, where);
1325
1472
  checkPassword(type, change?.next, where);
1326
1473
  const hasher = requireHasher(context, where);
1327
- return toUser(await writeUser(context, user, type, options, where, async (record, now) => {
1474
+ const written = await writeUser(context, user, type, options, where, async (record, now) => {
1328
1475
  if (record.password === null || !await passwordMatches(context, record, change.current, where)) {
1329
1476
  throw new CredentialError2("CREDENTIALS_INVALID", `${where}: the current password does not match`, {
1330
1477
  reason: record.password === null ? "noPassword" : "wrongPassword",
@@ -1338,48 +1485,13 @@ function typeApi(context, type) {
1338
1485
  updatedAt: now
1339
1486
  }
1340
1487
  };
1341
- }));
1488
+ });
1489
+ await endSignInsWaiting(context, written.id);
1490
+ return toUser(written);
1342
1491
  },
1343
1492
  secondFactor: secondFactor.api,
1344
1493
  signInCode,
1345
- verifyEmail: {
1346
- async send(user) {
1347
- const where = at("verifyEmail.send");
1348
- const record = await getRecord(context, idOf(user), type.name, where);
1349
- return issue("verifyEmail", record, where);
1350
- },
1351
- async confirm(secret) {
1352
- const where = at("verifyEmail.confirm");
1353
- const { user } = await redeem("verifyEmail", secret, where);
1354
- return toUser(await writeUser(context, user.id, type, undefined, where, (_, now) => ({
1355
- emailVerifiedAt: now
1356
- })));
1357
- }
1358
- },
1359
- resetPassword: {
1360
- async request(email) {
1361
- const where = at("resetPassword.request");
1362
- passwordRule(where);
1363
- const record = await holderOfEmail(context, type, String(email));
1364
- if (record === null)
1365
- return null;
1366
- const issued = await issue("resetPassword", record, where);
1367
- return { ...issued, user: toUser(record) };
1368
- },
1369
- async confirm(secret, password) {
1370
- const where = at("resetPassword.confirm");
1371
- passwordRule(where);
1372
- checkPassword(type, password, where);
1373
- const hash = await requireHasher(context, where).hash(password);
1374
- const { user } = await redeem("resetPassword", secret, where);
1375
- const written = await writeUser(context, user.id, type, undefined, where, (record, now) => ({
1376
- password: { hash, updatedAt: now },
1377
- ...record.emailVerifiedAt === null ? { emailVerifiedAt: now } : {}
1378
- }));
1379
- await store.sessions.revokeUserSessions(written.id, clock.now());
1380
- return toUser(written);
1381
- }
1382
- }
1494
+ ...emailFlows(context, type, at)
1383
1495
  };
1384
1496
  }
1385
1497
 
@@ -1407,7 +1519,7 @@ function janus(config) {
1407
1519
  if (relations !== null && typeof relations.deleteEntity !== "function") {
1408
1520
  throw new TypeError(`${where}: relations must be a relation store — relations.deleteEntity is missing`);
1409
1521
  }
1410
- const context = createContext(resolved, guardStores(config.store), relations === null ? null : guardRelations(relations), capabilities, config.clock ?? systemClock, hasher, verifiers);
1522
+ const context = createContext(resolved, guardStores(config.store), relations === null ? null : guardRelations(relations), capabilities, config.clock ?? systemClock, hasher, verifiers, resolveEvents(config.events, where));
1411
1523
  const shared = sharedApi(context);
1412
1524
  const types = Object.fromEntries([...resolved.types.values()].map((type) => [
1413
1525
  type.name,
@@ -1455,5 +1567,5 @@ export {
1455
1567
  systemClock
1456
1568
  };
1457
1569
 
1458
- //# debugId=9A2F9DDAD478673A64756E2164756E21
1570
+ //# debugId=52492AAB2CF93D3A64756E2164756E21
1459
1571
  //# sourceMappingURL=index.js.map