@nxgt/janus 0.5.0 → 0.6.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.
package/dist/index.js CHANGED
@@ -278,7 +278,8 @@ function resolveConfig(config, where) {
278
278
  types,
279
279
  tokenTtlMs: {
280
280
  verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
281
- resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`)
281
+ resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`),
282
+ signInCode: parseDuration(config.tokens?.signInCode ?? "10m", `${where}: tokens.signInCode`)
282
283
  },
283
284
  cookie: resolveCookie(config.cookie ?? {}, where),
284
285
  secondFactor: resolveSecondFactor(config.secondFactor, where)
@@ -579,6 +580,14 @@ async function writeUser(context, user, type, options, where, patchOf) {
579
580
  const patch = await patchOf(record, now);
580
581
  return context.store.users.updateUser(record.id, { ...patch, updatedAt: now }, record.version);
581
582
  }
583
+ async function holderOfEmail(context, type, email) {
584
+ const wanted = normalizeEmail(email);
585
+ if (!isStorable(wanted))
586
+ return null;
587
+ const record = await context.store.users.findUserByLogin(type.name, wanted);
588
+ const held = record === null ? null : emailOf(type, record.fields);
589
+ return held !== null && normalizeEmail(held) === wanted ? record : null;
590
+ }
582
591
 
583
592
  // src/auth/port/assert-stores.ts
584
593
  var REQUIRED = {
@@ -782,6 +791,30 @@ function headersOf(request) {
782
791
  }
783
792
 
784
793
  // src/auth/one-time.ts
794
+ import { randomInt, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
795
+ var CODE_ATTEMPTS = 5;
796
+ async function countCodeAttempt(context, secret, kind, where, userType) {
797
+ const token = await context.store.tokens.countAttempt(hashSecret(secret), kind);
798
+ refuseUnusable(token, context.clock.now(), where, "challenge");
799
+ if (token.attempts > CODE_ATTEMPTS) {
800
+ throw codeInvalid(where, token.userId, userType, 0);
801
+ }
802
+ return { token, attemptsLeft: CODE_ATTEMPTS - token.attempts };
803
+ }
804
+ function codeInvalid(where, userId, userType, attemptsLeft) {
805
+ return new TokenError2("CODE_INVALID", `${where}: the code does not match, or was already used`, {
806
+ operation: where,
807
+ userId,
808
+ userType,
809
+ ...attemptsLeft === undefined ? {} : { attemptsLeft }
810
+ });
811
+ }
812
+ function refuseStale(type, user, token, where, noun) {
813
+ const email = emailOf(type, user.fields);
814
+ if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
815
+ throw new TokenError2("TOKEN_STALE", `${where}: the ${noun} was sent to an e-mail the user no longer has`, { operation: where, userId: user.id, userType: type.name });
816
+ }
817
+ }
785
818
  function refuseUnusable(token, now, where, noun) {
786
819
  if (token === null) {
787
820
  throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
@@ -806,7 +839,25 @@ async function spendOneTime(context, secret, kind, where, noun) {
806
839
  refuseUnusable(token, now, where, noun);
807
840
  return token;
808
841
  }
842
+ async function burnOneTime(context, secret, kind) {
843
+ await context.store.tokens.consumeToken(hashSecret(secret), kind, context.clock.now());
844
+ }
845
+ var hashCode = (challenge, code) => hashSecret(`${challenge}${code}`);
846
+ function codeMatches(token, challenge, code) {
847
+ if (token.codeHash === null || !/^\d{6}$/.test(code))
848
+ return false;
849
+ const given = Buffer.from(hashCode(challenge, code));
850
+ const held = Buffer.from(token.codeHash);
851
+ return given.length === held.length && timingSafeEqual2(given, held);
852
+ }
809
853
  async function issueOneTime(context, token) {
854
+ return insertOneTime(context, token, null);
855
+ }
856
+ async function issueCode(context, token) {
857
+ const code = String(randomInt(1e6)).padStart(6, "0");
858
+ return { ...await insertOneTime(context, token, code), code };
859
+ }
860
+ async function insertOneTime(context, token, code) {
810
861
  const now = context.clock.now();
811
862
  const secret = mintSecret();
812
863
  const expiresAt = new Date(now.getTime() + token.ttlMs);
@@ -815,7 +866,7 @@ async function issueOneTime(context, token) {
815
866
  kind: token.kind,
816
867
  userId: token.userId,
817
868
  address: token.address,
818
- codeHash: null,
869
+ codeHash: code === null ? null : hashCode(secret, code),
819
870
  attempts: 0,
820
871
  expiresAt,
821
872
  spentAt: null,
@@ -925,17 +976,8 @@ function acceptCode(configured, record, factor, code, now, where) {
925
976
  secret: keyId === configured.sealer.sealWith ? factor.secret : seal(configured.sealer, plain, record.id)
926
977
  };
927
978
  }
928
- function codeInvalid(type, where, userId, attemptsLeft) {
929
- return new TokenError2("CODE_INVALID", `${where}: the code does not match, or was already used`, {
930
- operation: where,
931
- userId,
932
- userType: type.name,
933
- ...attemptsLeft === undefined ? {} : { attemptsLeft }
934
- });
935
- }
936
979
 
937
980
  // src/auth/second-factor/challenge.ts
938
- var CHALLENGE_ATTEMPTS = 5;
939
981
  function challengeFlows(context, type, at) {
940
982
  const { store, clock } = context;
941
983
  const spend = (challenge, where) => spendOneTime(context, challenge, "secondFactor", where, "challenge");
@@ -954,12 +996,7 @@ function challengeFlows(context, type, at) {
954
996
  const where = at("secondFactor.confirm");
955
997
  const configured = requireSettings(context, where, "a second factor is being confirmed");
956
998
  const secret = String(challenge);
957
- const token = await store.tokens.countAttempt(hashSecret(secret), "secondFactor");
958
- refuseUnusable(token, clock.now(), where, "challenge");
959
- if (token.attempts > CHALLENGE_ATTEMPTS) {
960
- throw codeInvalid(type, where, token.userId, 0);
961
- }
962
- const attemptsLeft = CHALLENGE_ATTEMPTS - token.attempts;
999
+ const { token, attemptsLeft } = await countCodeAttempt(context, secret, "secondFactor", where, type.name);
963
1000
  const record = await findRecord(context, token.userId, type.name);
964
1001
  if (record === null)
965
1002
  throw unknownOneTime(where, "challenge");
@@ -977,9 +1014,10 @@ function challengeFlows(context, type, at) {
977
1014
  const now = clock.now();
978
1015
  const accepted = acceptCode(configured, record, record.secondFactor, String(code), now, where);
979
1016
  if (accepted === null) {
980
- if (attemptsLeft === 0)
981
- await spend(secret, where);
982
- throw codeInvalid(type, where, record.id, attemptsLeft);
1017
+ if (attemptsLeft === 0) {
1018
+ await burnOneTime(context, secret, "secondFactor");
1019
+ }
1020
+ throw codeInvalid(where, record.id, type.name, attemptsLeft);
983
1021
  }
984
1022
  const written = await store.users.updateUser(record.id, { secondFactor: accepted, updatedAt: now }, record.version);
985
1023
  await spend(secret, where);
@@ -1038,7 +1076,7 @@ function lifecycleFlows(context, type, at) {
1038
1076
  }
1039
1077
  const accepted = acceptCode(configured, record, factor, String(code), now, where);
1040
1078
  if (accepted === null)
1041
- throw codeInvalid(type, where, record.id);
1079
+ throw codeInvalid(where, record.id, type.name);
1042
1080
  return { secondFactor: { ...accepted, confirmedAt: now } };
1043
1081
  });
1044
1082
  return toUser(written);
@@ -1061,8 +1099,55 @@ function secondFactorFlows(context, type, at) {
1061
1099
  };
1062
1100
  return {
1063
1101
  api,
1064
- challenge: challenges.issue,
1065
- required: (record) => isActive(record.secondFactor)
1102
+ finish: (record, where) => isActive(record.secondFactor) ? challenges.issue(record, where) : openSession(context, type, record)
1103
+ };
1104
+ }
1105
+
1106
+ // src/auth/sign-in-code.ts
1107
+ function signInCodeFlows(context, type, at, finish) {
1108
+ return {
1109
+ async request(email) {
1110
+ const record = await holderOfEmail(context, type, String(email));
1111
+ if (record === null || !record.active)
1112
+ return null;
1113
+ const { secret, code, expiresAt } = await issueCode(context, {
1114
+ kind: "signInCode",
1115
+ userId: record.id,
1116
+ address: String(record.fields[type.email]),
1117
+ ttlMs: context.config.tokenTtlMs.signInCode
1118
+ });
1119
+ return {
1120
+ code,
1121
+ challenge: secret,
1122
+ email: String(record.fields[type.email]),
1123
+ expiresAt,
1124
+ user: toUser(record)
1125
+ };
1126
+ },
1127
+ async confirm(challenge, code) {
1128
+ const where = at("signInCode.confirm");
1129
+ const secret = String(challenge);
1130
+ const { token, attemptsLeft } = await countCodeAttempt(context, secret, "signInCode", where, type.name);
1131
+ const user = await findRecord(context, token.userId, type.name);
1132
+ if (user === null)
1133
+ throw unknownOneTime(where, "challenge");
1134
+ if (!codeMatches(token, secret, String(code))) {
1135
+ if (token.attempts === CODE_ATTEMPTS) {
1136
+ await burnOneTime(context, secret, "signInCode");
1137
+ }
1138
+ throw codeInvalid(where, user.id, type.name, attemptsLeft);
1139
+ }
1140
+ await spendOneTime(context, secret, "signInCode", where, "challenge");
1141
+ refuseStale(type, user, token, where, "code");
1142
+ if (!user.active) {
1143
+ throw new UserInactiveError2(`${where}: the user is inactive`, {
1144
+ userId: user.id,
1145
+ userType: type.name
1146
+ });
1147
+ }
1148
+ const proved = user.emailVerifiedAt === null ? await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now })) : user;
1149
+ return finish(proved, where);
1150
+ }
1066
1151
  };
1067
1152
  }
1068
1153
 
@@ -1071,6 +1156,7 @@ function typeApi(context, type) {
1071
1156
  const { store, clock } = context;
1072
1157
  const at = (operation) => context.config.single ? operation : `${type.name}.${operation}`;
1073
1158
  const secondFactor = secondFactorFlows(context, type, at);
1159
+ const signInCode = signInCodeFlows(context, type, at, secondFactor.finish);
1074
1160
  const byLogin = async (login) => isStorable(login) ? store.users.findUserByLogin(type.name, login) : null;
1075
1161
  const insert = async (input, where) => {
1076
1162
  const { password, active, ...rest } = input ?? {};
@@ -1104,10 +1190,7 @@ function typeApi(context, type) {
1104
1190
  const user = await findRecord(context, token.userId, type.name);
1105
1191
  if (user === null)
1106
1192
  throw unknownOneTime(where, "token");
1107
- const email = emailOf(type, user.fields);
1108
- if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
1109
- throw new TokenError2("TOKEN_STALE", `${where}: the token was sent to an e-mail the user no longer has`, { operation: where, userId: user.id, userType: type.name });
1110
- }
1193
+ refuseStale(type, user, token, where, "token");
1111
1194
  return { token, user };
1112
1195
  };
1113
1196
  const issue = async (kind, user, where) => {
@@ -1220,8 +1303,7 @@ function typeApi(context, type) {
1220
1303
  userType: type.name
1221
1304
  });
1222
1305
  }
1223
- const signedIn = await rehashed(context, record, String(password));
1224
- return secondFactor.required(signedIn) ? secondFactor.challenge(signedIn, where) : openSession(context, type, signedIn);
1306
+ return secondFactor.finish(await rehashed(context, record, String(password)), where);
1225
1307
  },
1226
1308
  async findByLogin(login) {
1227
1309
  const rule = passwordRule(at("findByLogin"));
@@ -1259,6 +1341,7 @@ function typeApi(context, type) {
1259
1341
  }));
1260
1342
  },
1261
1343
  secondFactor: secondFactor.api,
1344
+ signInCode,
1262
1345
  verifyEmail: {
1263
1346
  async send(user) {
1264
1347
  const where = at("verifyEmail.send");
@@ -1277,12 +1360,9 @@ function typeApi(context, type) {
1277
1360
  async request(email) {
1278
1361
  const where = at("resetPassword.request");
1279
1362
  passwordRule(where);
1280
- const wanted = normalizeEmail(String(email));
1281
- const record = await byLogin(wanted);
1282
- const held = record === null ? null : emailOf(type, record.fields);
1283
- if (record === null || held === null || normalizeEmail(held) !== wanted) {
1363
+ const record = await holderOfEmail(context, type, String(email));
1364
+ if (record === null)
1284
1365
  return null;
1285
- }
1286
1366
  const issued = await issue("resetPassword", record, where);
1287
1367
  return { ...issued, user: toUser(record) };
1288
1368
  },
@@ -1375,5 +1455,5 @@ export {
1375
1455
  systemClock
1376
1456
  };
1377
1457
 
1378
- //# debugId=FA6925B96B6EEFD664756E2164756E21
1458
+ //# debugId=9A2F9DDAD478673A64756E2164756E21
1379
1459
  //# sourceMappingURL=index.js.map