@nxgt/janus 0.3.0 → 0.5.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 (58) hide show
  1. package/README.md +135 -15
  2. package/dist/auth/config.d.ts +25 -1
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts.map +1 -1
  5. package/dist/auth/index.d.ts +4 -3
  6. package/dist/auth/index.d.ts.map +1 -1
  7. package/dist/auth/one-time.d.ts +39 -0
  8. package/dist/auth/one-time.d.ts.map +1 -0
  9. package/dist/auth/port/types.d.ts +74 -3
  10. package/dist/auth/port/types.d.ts.map +1 -1
  11. package/dist/auth/sealing.d.ts +39 -0
  12. package/dist/auth/sealing.d.ts.map +1 -0
  13. package/dist/auth/second-factor/challenge.d.ts +20 -0
  14. package/dist/auth/second-factor/challenge.d.ts.map +1 -0
  15. package/dist/auth/second-factor/factor.d.ts +30 -0
  16. package/dist/auth/second-factor/factor.d.ts.map +1 -0
  17. package/dist/auth/second-factor/flows.d.ts +19 -0
  18. package/dist/auth/second-factor/flows.d.ts.map +1 -0
  19. package/dist/auth/second-factor/lifecycle.d.ts +8 -0
  20. package/dist/auth/second-factor/lifecycle.d.ts.map +1 -0
  21. package/dist/auth/sessions.d.ts.map +1 -1
  22. package/dist/auth/totp.d.ts +36 -0
  23. package/dist/auth/totp.d.ts.map +1 -0
  24. package/dist/auth/types.d.ts +91 -7
  25. package/dist/auth/types.d.ts.map +1 -1
  26. package/dist/auth/users.d.ts +2 -2
  27. package/dist/auth/users.d.ts.map +1 -1
  28. package/dist/chunks/{index-qwfkhqkk.js → index-06vp9c5r.js} +12 -3
  29. package/dist/chunks/{index-qwfkhqkk.js.map → index-06vp9c5r.js.map} +3 -3
  30. package/dist/chunks/{index-53y1afjz.js → index-5vr13kkb.js} +2 -2
  31. package/dist/chunks/{index-mgh85djb.js → index-c4v27jfr.js} +2 -2
  32. package/dist/chunks/{index-8tksqzkr.js → index-gbwn2tts.js} +14 -3
  33. package/dist/chunks/{index-8tksqzkr.js.map → index-gbwn2tts.js.map} +3 -3
  34. package/dist/conformance/cases/outage.d.ts.map +1 -1
  35. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  36. package/dist/conformance/cases/users.d.ts.map +1 -1
  37. package/dist/conformance/fixtures.d.ts.map +1 -1
  38. package/dist/conformance/index.js +122 -5
  39. package/dist/conformance/index.js.map +6 -6
  40. package/dist/errors/janus-error.d.ts +28 -2
  41. package/dist/errors/janus-error.d.ts.map +1 -1
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +390 -40
  45. package/dist/index.js.map +15 -8
  46. package/dist/permissions/index.js +3 -3
  47. package/docs/README.md +2 -1
  48. package/docs/guide/adapters.md +129 -3
  49. package/docs/guide/errors.md +18 -6
  50. package/docs/guide/second-factor.md +569 -0
  51. package/docs/guide/sessions.md +18 -0
  52. package/docs/guide/users.md +8 -2
  53. package/docs/guide/vocabulary.md +6 -1
  54. package/docs/roadmap.md +33 -46
  55. package/docs/troubleshooting.md +276 -5
  56. package/package.json +1 -1
  57. /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
  58. /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
package/dist/index.js CHANGED
@@ -5,13 +5,13 @@ import {
5
5
  MAX_PAGE_SIZE2,
6
6
  pageLimit2,
7
7
  invalidCursor2
8
- } from "./chunks/index-53y1afjz.js";
8
+ } from "./chunks/index-5vr13kkb.js";
9
9
  import {
10
10
  mintId2,
11
11
  isId2,
12
12
  mintedAt2,
13
13
  createMemoryStores2
14
- } from "./chunks/index-8tksqzkr.js";
14
+ } from "./chunks/index-gbwn2tts.js";
15
15
  import {
16
16
  JanusError2,
17
17
  StoreFailure2,
@@ -21,6 +21,7 @@ import {
21
21
  CredentialError2,
22
22
  UserInactiveError2,
23
23
  TokenError2,
24
+ SecondFactorError2,
24
25
  InvalidCursorError2,
25
26
  UnsupportedError2,
26
27
  PermissionDepthError2,
@@ -33,7 +34,7 @@ import {
33
34
  formatTuple2,
34
35
  parseTuple2,
35
36
  parseSubject2
36
- } from "./chunks/index-qwfkhqkk.js";
37
+ } from "./chunks/index-06vp9c5r.js";
37
38
 
38
39
  // src/auth/hashers.ts
39
40
  import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
@@ -140,6 +141,69 @@ function parseDuration(value, where) {
140
141
  return ms;
141
142
  }
142
143
 
144
+ // src/auth/sealing.ts
145
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "node:crypto";
146
+ var TAG_BYTES = 16;
147
+ var KEY_ID = /^[A-Za-z0-9_-]{1,64}$/;
148
+ var KEY = /^[A-Za-z0-9+/_-]{43}=?$/;
149
+ function resolveSealer(keys, where) {
150
+ if (!Array.isArray(keys) || keys.length === 0) {
151
+ throw new TypeError(`${where}: expected at least one key — [{ id, key }], the first seals`);
152
+ }
153
+ const decoded = new Map;
154
+ for (const entry of keys) {
155
+ const id = entry?.id;
156
+ if (typeof id !== "string" || !KEY_ID.test(id)) {
157
+ throw new TypeError(`${where}: every key needs an id of letters, digits, _ and -, at most 64 of them`);
158
+ }
159
+ if (decoded.has(id)) {
160
+ throw new TypeError(`${where}: two keys have the id "${id}"`);
161
+ }
162
+ const key = entry.key;
163
+ if (typeof key !== "string" || !KEY.test(key)) {
164
+ throw new TypeError(`${where}: the key "${id}" is not 32 bytes in base64 — make one with openssl rand -base64 32`);
165
+ }
166
+ decoded.set(id, Buffer.from(key, "base64"));
167
+ }
168
+ return { sealWith: keys[0].id, keys: decoded };
169
+ }
170
+ function seal(sealer, plain, boundTo) {
171
+ const key = sealer.keys.get(sealer.sealWith);
172
+ const iv = randomBytes2(12);
173
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
174
+ cipher.setAAD(Buffer.from(boundTo));
175
+ const sealed = Buffer.concat([
176
+ cipher.update(plain, "utf8"),
177
+ cipher.final(),
178
+ cipher.getAuthTag()
179
+ ]);
180
+ return `v1.${sealer.sealWith}.${iv.toString("base64url")}.${sealed.toString("base64url")}`;
181
+ }
182
+ function unseal(sealer, sealed, boundTo, where) {
183
+ const parts = sealed.split(".");
184
+ const [version, keyId, iv, body] = parts;
185
+ const bytes = Buffer.from(body ?? "", "base64url");
186
+ if (parts.length !== 4 || version !== "v1" || keyId === undefined || iv === undefined || bytes.length <= TAG_BYTES) {
187
+ throw new TypeError(`${where}: the stored secret is not a sealed one`);
188
+ }
189
+ const key = sealer.keys.get(keyId);
190
+ if (key === undefined) {
191
+ throw new TypeError(`${where}: the secret is sealed with the key "${keyId}", which secondFactor.keys no longer holds — keep a key until no secret is sealed with it`);
192
+ }
193
+ try {
194
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64url"), { authTagLength: TAG_BYTES });
195
+ decipher.setAAD(Buffer.from(boundTo));
196
+ decipher.setAuthTag(bytes.subarray(bytes.length - TAG_BYTES));
197
+ const plain = Buffer.concat([
198
+ decipher.update(bytes.subarray(0, bytes.length - TAG_BYTES)),
199
+ decipher.final()
200
+ ]).toString("utf8");
201
+ return { plain, keyId };
202
+ } catch (cause) {
203
+ throw new TypeError(`${where}: the secret does not open with the key "${keyId}" — was that key changed under the same id, or the secret copied from another user?`, { cause });
204
+ }
205
+ }
206
+
143
207
  // src/auth/config.ts
144
208
  var SINGLE_TYPE = "user";
145
209
  var RESERVED_FIELDS = [
@@ -148,6 +212,7 @@ var RESERVED_FIELDS = [
148
212
  "emailVerified",
149
213
  "active",
150
214
  "hasPassword",
215
+ "hasSecondFactor",
151
216
  "version",
152
217
  "createdAt",
153
218
  "updatedAt",
@@ -215,7 +280,21 @@ function resolveConfig(config, where) {
215
280
  verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
216
281
  resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`)
217
282
  },
218
- cookie: resolveCookie(config.cookie ?? {}, where)
283
+ cookie: resolveCookie(config.cookie ?? {}, where),
284
+ secondFactor: resolveSecondFactor(config.secondFactor, where)
285
+ };
286
+ }
287
+ function resolveSecondFactor(config, where) {
288
+ if (config === undefined)
289
+ return null;
290
+ const at = `${where}: secondFactor`;
291
+ if (typeof config?.issuer !== "string" || config.issuer.trim() === "") {
292
+ throw new TypeError(`${at}.issuer must name your application — the authenticator app shows it beside the account`);
293
+ }
294
+ return {
295
+ issuer: config.issuer,
296
+ sealer: resolveSealer(config.keys, `${at}.keys`),
297
+ challengeTtlMs: parseDuration(config.challenge ?? "5m", `${at}.challenge`)
219
298
  };
220
299
  }
221
300
  function resolveType(name, type, at, schemaKey) {
@@ -336,6 +415,7 @@ function toUser(record) {
336
415
  emailVerified: record.emailVerifiedAt !== null,
337
416
  active: record.active,
338
417
  hasPassword: record.password !== null,
418
+ hasSecondFactor: record.secondFactor?.confirmedAt != null,
339
419
  version: record.version,
340
420
  createdAt: record.createdAt,
341
421
  updatedAt: record.updatedAt
@@ -518,7 +598,7 @@ var REQUIRED = {
518
598
  "revokeUserSessions",
519
599
  "deleteUserSessions"
520
600
  ],
521
- tokens: ["insertToken", "consumeToken", "deleteUserTokens"]
601
+ tokens: ["insertToken", "consumeToken", "countAttempt", "deleteUserTokens"]
522
602
  };
523
603
  function assertStores(stores, where) {
524
604
  if (typeof stores !== "object" || stores === null) {
@@ -544,9 +624,9 @@ function assertStores(stores, where) {
544
624
  }
545
625
 
546
626
  // src/auth/secrets.ts
547
- import { createHash, randomBytes as randomBytes2 } from "node:crypto";
627
+ import { createHash, randomBytes as randomBytes3 } from "node:crypto";
548
628
  function mintSecret() {
549
- return randomBytes2(32).toString("base64url");
629
+ return randomBytes3(32).toString("base64url");
550
630
  }
551
631
  function hashSecret(secret) {
552
632
  return createHash("sha256").update(secret).digest("hex");
@@ -570,7 +650,12 @@ async function openSession(context, type, user) {
570
650
  createdAt: now
571
651
  };
572
652
  await context.store.sessions.insertSession(record);
573
- return { user: toUser(user), session: toSession(record), token };
653
+ return {
654
+ status: "signedIn",
655
+ user: toUser(user),
656
+ session: toSession(record),
657
+ token
658
+ };
574
659
  }
575
660
  function sharedApi(context) {
576
661
  const { store, clock, config } = context;
@@ -696,10 +781,296 @@ function headersOf(request) {
696
781
  return request;
697
782
  }
698
783
 
784
+ // src/auth/one-time.ts
785
+ function refuseUnusable(token, now, where, noun) {
786
+ if (token === null) {
787
+ throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
788
+ operation: where
789
+ });
790
+ }
791
+ if (token.spentAt !== null) {
792
+ throw new TokenError2("TOKEN_SPENT", `${where}: the ${noun} was already used`, { operation: where });
793
+ }
794
+ if (token.expiresAt.getTime() <= now.getTime()) {
795
+ throw new TokenError2("TOKEN_EXPIRED", `${where}: the ${noun} has expired`, {
796
+ operation: where
797
+ });
798
+ }
799
+ }
800
+ var unknownOneTime = (where, noun) => new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
801
+ operation: where
802
+ });
803
+ async function spendOneTime(context, secret, kind, where, noun) {
804
+ const now = context.clock.now();
805
+ const token = await context.store.tokens.consumeToken(hashSecret(secret), kind, now);
806
+ refuseUnusable(token, now, where, noun);
807
+ return token;
808
+ }
809
+ async function issueOneTime(context, token) {
810
+ const now = context.clock.now();
811
+ const secret = mintSecret();
812
+ const expiresAt = new Date(now.getTime() + token.ttlMs);
813
+ await context.store.tokens.insertToken({
814
+ tokenHash: hashSecret(secret),
815
+ kind: token.kind,
816
+ userId: token.userId,
817
+ address: token.address,
818
+ codeHash: null,
819
+ attempts: 0,
820
+ expiresAt,
821
+ spentAt: null,
822
+ createdAt: now
823
+ });
824
+ return { secret, expiresAt };
825
+ }
826
+
827
+ // src/auth/totp.ts
828
+ import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
829
+ var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
830
+ var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
831
+ function mintTotpSecret() {
832
+ return toBase32(randomBytes4(20));
833
+ }
834
+ function toBase32(bytes) {
835
+ let bits = 0;
836
+ let value = 0;
837
+ let out = "";
838
+ for (const byte of bytes) {
839
+ value = value << 8 | byte;
840
+ bits += 8;
841
+ while (bits >= 5) {
842
+ out += BASE32[value >>> bits - 5 & 31];
843
+ bits -= 5;
844
+ }
845
+ }
846
+ if (bits > 0)
847
+ out += BASE32[value << 5 - bits & 31];
848
+ return out;
849
+ }
850
+ function fromBase32(text) {
851
+ let bits = 0;
852
+ let value = 0;
853
+ const out = [];
854
+ for (const char of text) {
855
+ const index = BASE32.indexOf(char);
856
+ if (index === -1)
857
+ throw new TypeError("totp: the secret is not base32");
858
+ value = value << 5 | index;
859
+ bits += 5;
860
+ if (bits >= 8) {
861
+ out.push(value >>> bits - 8 & 255);
862
+ bits -= 8;
863
+ }
864
+ }
865
+ return Buffer.from(out);
866
+ }
867
+ var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
868
+ function codeAt(secret, step) {
869
+ const counter = Buffer.alloc(8);
870
+ counter.writeBigUInt64BE(BigInt(step));
871
+ const digest = createHmac("sha1", secret).update(counter).digest();
872
+ const offset = digest[19] & 15;
873
+ const binary = digest.readUInt32BE(offset) & 2147483647;
874
+ return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
875
+ }
876
+ function matchStep(secret, code, now, lastStep) {
877
+ if (!/^\d{6}$/.test(code))
878
+ return null;
879
+ const current = stepAt(now);
880
+ let matched = null;
881
+ for (let step = current - TOTP.drift;step <= current + TOTP.drift; step++) {
882
+ const fresh = lastStep === null || step > lastStep;
883
+ if (equalCodes(codeAt(secret, step), code) && fresh && matched === null) {
884
+ matched = step;
885
+ }
886
+ }
887
+ return matched;
888
+ }
889
+ function equalCodes(a, b) {
890
+ let difference = a.length ^ b.length;
891
+ for (let i = 0;i < a.length; i++) {
892
+ difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
893
+ }
894
+ return difference === 0;
895
+ }
896
+ function otpauthUri(issuer, account, secret) {
897
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
898
+ const query = new URLSearchParams({
899
+ secret,
900
+ issuer,
901
+ algorithm: "SHA1",
902
+ digits: String(TOTP.digits),
903
+ period: String(TOTP.stepMs / 1000)
904
+ });
905
+ return `otpauth://totp/${label}?${query}`;
906
+ }
907
+
908
+ // src/auth/second-factor/factor.ts
909
+ var isActive = (factor) => factor !== null && factor.confirmedAt !== null;
910
+ function requireSettings(context, where, why) {
911
+ const configured = context.config.secondFactor;
912
+ if (configured === null) {
913
+ throw new TypeError(`${where}: ${why}, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`);
914
+ }
915
+ return configured;
916
+ }
917
+ function acceptCode(configured, record, factor, code, now, where) {
918
+ const { plain, keyId } = unseal(configured.sealer, factor.secret, record.id, where);
919
+ const step = matchStep(fromBase32(plain), code, now, factor.lastStep);
920
+ if (step === null)
921
+ return null;
922
+ return {
923
+ ...factor,
924
+ lastStep: step,
925
+ secret: keyId === configured.sealer.sealWith ? factor.secret : seal(configured.sealer, plain, record.id)
926
+ };
927
+ }
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
+
937
+ // src/auth/second-factor/challenge.ts
938
+ var CHALLENGE_ATTEMPTS = 5;
939
+ function challengeFlows(context, type, at) {
940
+ const { store, clock } = context;
941
+ const spend = (challenge, where) => spendOneTime(context, challenge, "secondFactor", where, "challenge");
942
+ return {
943
+ async issue(record, where) {
944
+ const configured = requireSettings(context, where, "the user's second factor is active");
945
+ const { secret, expiresAt } = await issueOneTime(context, {
946
+ kind: "secondFactor",
947
+ userId: record.id,
948
+ address: "",
949
+ ttlMs: configured.challengeTtlMs
950
+ });
951
+ return { status: "secondFactor", challenge: secret, expiresAt };
952
+ },
953
+ async confirm(challenge, code) {
954
+ const where = at("secondFactor.confirm");
955
+ const configured = requireSettings(context, where, "a second factor is being confirmed");
956
+ 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;
963
+ const record = await findRecord(context, token.userId, type.name);
964
+ if (record === null)
965
+ throw unknownOneTime(where, "challenge");
966
+ if (!record.active) {
967
+ await spend(secret, where);
968
+ throw new UserInactiveError2(`${where}: the user is inactive`, {
969
+ userId: record.id,
970
+ userType: type.name
971
+ });
972
+ }
973
+ if (!isActive(record.secondFactor)) {
974
+ await spend(secret, where);
975
+ throw new SecondFactorError2("SECOND_FACTOR_NOT_ENROLLED", `${where}: the user no longer has a second factor — sign in again`, { operation: where, userId: record.id, userType: type.name });
976
+ }
977
+ const now = clock.now();
978
+ const accepted = acceptCode(configured, record, record.secondFactor, String(code), now, where);
979
+ if (accepted === null) {
980
+ if (attemptsLeft === 0)
981
+ await spend(secret, where);
982
+ throw codeInvalid(type, where, record.id, attemptsLeft);
983
+ }
984
+ const written = await store.users.updateUser(record.id, { secondFactor: accepted, updatedAt: now }, record.version);
985
+ await spend(secret, where);
986
+ return openSession(context, type, written);
987
+ }
988
+ };
989
+ }
990
+
991
+ // src/auth/second-factor/lifecycle.ts
992
+ function lifecycleFlows(context, type, at) {
993
+ const accountOf = (record, where) => {
994
+ if (type.password === null) {
995
+ throw new TypeError(`${where}: the ${type.name} type does not sign in with a password, so it has no second factor`);
996
+ }
997
+ return String(record.fields[type.password.login]);
998
+ };
999
+ const refuse = (code, message, where, record) => new SecondFactorError2(code, `${where}: ${message}`, {
1000
+ operation: where,
1001
+ userId: record.id,
1002
+ userType: type.name
1003
+ });
1004
+ return {
1005
+ async enroll(user, options) {
1006
+ const where = at("secondFactor.enroll");
1007
+ const configured = requireSettings(context, where, "a second factor is being enrolled");
1008
+ const secret = mintTotpSecret();
1009
+ const written = await writeUser(context, user, type, options, where, (record) => {
1010
+ if (isActive(record.secondFactor)) {
1011
+ throw refuse("SECOND_FACTOR_ACTIVE", "the user's second factor is active — disable it first", where, record);
1012
+ }
1013
+ accountOf(record, where);
1014
+ return {
1015
+ secondFactor: {
1016
+ method: "totp",
1017
+ secret: seal(configured.sealer, secret, record.id),
1018
+ confirmedAt: null,
1019
+ lastStep: null
1020
+ }
1021
+ };
1022
+ });
1023
+ return {
1024
+ secret,
1025
+ uri: otpauthUri(configured.issuer, accountOf(written, where), secret)
1026
+ };
1027
+ },
1028
+ async activate(user, code, options) {
1029
+ const where = at("secondFactor.activate");
1030
+ const configured = requireSettings(context, where, "a second factor is being activated");
1031
+ const written = await writeUser(context, user, type, options, where, (record, now) => {
1032
+ const factor = record.secondFactor;
1033
+ if (factor === null) {
1034
+ throw refuse("SECOND_FACTOR_NOT_ENROLLED", "the user has no second factor waiting — call enroll first", where, record);
1035
+ }
1036
+ if (isActive(factor)) {
1037
+ throw refuse("SECOND_FACTOR_ACTIVE", "the user's second factor is already active", where, record);
1038
+ }
1039
+ const accepted = acceptCode(configured, record, factor, String(code), now, where);
1040
+ if (accepted === null)
1041
+ throw codeInvalid(type, where, record.id);
1042
+ return { secondFactor: { ...accepted, confirmedAt: now } };
1043
+ });
1044
+ return toUser(written);
1045
+ },
1046
+ async disable(user, options) {
1047
+ const where = at("secondFactor.disable");
1048
+ return toUser(await writeUser(context, user, type, options, where, () => ({
1049
+ secondFactor: null
1050
+ })));
1051
+ }
1052
+ };
1053
+ }
1054
+
1055
+ // src/auth/second-factor/flows.ts
1056
+ function secondFactorFlows(context, type, at) {
1057
+ const challenges = challengeFlows(context, type, at);
1058
+ const api = {
1059
+ ...lifecycleFlows(context, type, at),
1060
+ confirm: challenges.confirm
1061
+ };
1062
+ return {
1063
+ api,
1064
+ challenge: challenges.issue,
1065
+ required: (record) => isActive(record.secondFactor)
1066
+ };
1067
+ }
1068
+
699
1069
  // src/auth/users.ts
700
1070
  function typeApi(context, type) {
701
1071
  const { store, clock } = context;
702
1072
  const at = (operation) => context.config.single ? operation : `${type.name}.${operation}`;
1073
+ const secondFactor = secondFactorFlows(context, type, at);
703
1074
  const byLogin = async (login) => isStorable(login) ? store.users.findUserByLogin(type.name, login) : null;
704
1075
  const insert = async (input, where) => {
705
1076
  const { password, active, ...rest } = input ?? {};
@@ -721,6 +1092,7 @@ function typeApi(context, type) {
721
1092
  fields,
722
1093
  logins: loginsOf(type, fields, where),
723
1094
  password: hash,
1095
+ secondFactor: null,
724
1096
  emailVerifiedAt: null,
725
1097
  version: 0,
726
1098
  createdAt: now,
@@ -728,29 +1100,10 @@ function typeApi(context, type) {
728
1100
  });
729
1101
  };
730
1102
  const redeem = async (kind, secret, where) => {
731
- const now = clock.now();
732
- const token = await store.tokens.consumeToken(hashSecret(secret), kind, now);
733
- if (token === null) {
734
- throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such token`, {
735
- operation: where
736
- });
737
- }
738
- if (token.spentAt !== null) {
739
- throw new TokenError2("TOKEN_SPENT", `${where}: the token was already used`, {
740
- operation: where
741
- });
742
- }
743
- if (token.expiresAt.getTime() <= now.getTime()) {
744
- throw new TokenError2("TOKEN_EXPIRED", `${where}: the token has expired`, {
745
- operation: where
746
- });
747
- }
1103
+ const token = await spendOneTime(context, secret, kind, where, "token");
748
1104
  const user = await findRecord(context, token.userId, type.name);
749
- if (user === null) {
750
- throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such token`, {
751
- operation: where
752
- });
753
- }
1105
+ if (user === null)
1106
+ throw unknownOneTime(where, "token");
754
1107
  const email = emailOf(type, user.fields);
755
1108
  if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
756
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 });
@@ -766,17 +1119,11 @@ function typeApi(context, type) {
766
1119
  operation: where
767
1120
  });
768
1121
  }
769
- const now = clock.now();
770
- const secret = mintSecret();
771
- const expiresAt = new Date(now.getTime() + context.config.tokenTtlMs[kind]);
772
- await store.tokens.insertToken({
773
- tokenHash: hashSecret(secret),
1122
+ const { secret, expiresAt } = await issueOneTime(context, {
774
1123
  kind,
775
1124
  userId: user.id,
776
1125
  address: email,
777
- expiresAt,
778
- spentAt: null,
779
- createdAt: now
1126
+ ttlMs: context.config.tokenTtlMs[kind]
780
1127
  });
781
1128
  return { token: secret, email, expiresAt };
782
1129
  };
@@ -873,7 +1220,8 @@ function typeApi(context, type) {
873
1220
  userType: type.name
874
1221
  });
875
1222
  }
876
- return openSession(context, type, await rehashed(context, record, String(password)));
1223
+ const signedIn = await rehashed(context, record, String(password));
1224
+ return secondFactor.required(signedIn) ? secondFactor.challenge(signedIn, where) : openSession(context, type, signedIn);
877
1225
  },
878
1226
  async findByLogin(login) {
879
1227
  const rule = passwordRule(at("findByLogin"));
@@ -910,6 +1258,7 @@ function typeApi(context, type) {
910
1258
  };
911
1259
  }));
912
1260
  },
1261
+ secondFactor: secondFactor.api,
913
1262
  verifyEmail: {
914
1263
  async send(user) {
915
1264
  const where = at("verifyEmail.send");
@@ -995,6 +1344,7 @@ export {
995
1344
  MAX_PAGE_SIZE2 as MAX_PAGE_SIZE,
996
1345
  NotFoundError2 as NotFoundError,
997
1346
  PermissionDepthError2 as PermissionDepthError,
1347
+ SecondFactorError2 as SecondFactorError,
998
1348
  StoreConflict2 as StoreConflict,
999
1349
  StoreFailure2 as StoreFailure,
1000
1350
  TokenError2 as TokenError,
@@ -1025,5 +1375,5 @@ export {
1025
1375
  systemClock
1026
1376
  };
1027
1377
 
1028
- //# debugId=D645B9285399F02964756E2164756E21
1378
+ //# debugId=FA6925B96B6EEFD664756E2164756E21
1029
1379
  //# sourceMappingURL=index.js.map