@nxgt/janus 0.4.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.
Files changed (60) hide show
  1. package/README.md +159 -20
  2. package/dist/auth/config.d.ts +28 -1
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts +6 -0
  5. package/dist/auth/context.d.ts.map +1 -1
  6. package/dist/auth/index.d.ts +3 -2
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/one-time.d.ts +92 -0
  9. package/dist/auth/one-time.d.ts.map +1 -0
  10. package/dist/auth/port/types.d.ts +10 -7
  11. package/dist/auth/port/types.d.ts.map +1 -1
  12. package/dist/auth/sealing.d.ts +39 -0
  13. package/dist/auth/sealing.d.ts.map +1 -0
  14. package/dist/auth/second-factor/challenge.d.ts +14 -0
  15. package/dist/auth/second-factor/challenge.d.ts.map +1 -0
  16. package/dist/auth/second-factor/factor.d.ts +27 -0
  17. package/dist/auth/second-factor/factor.d.ts.map +1 -0
  18. package/dist/auth/second-factor/flows.d.ts +23 -0
  19. package/dist/auth/second-factor/flows.d.ts.map +1 -0
  20. package/dist/auth/second-factor/lifecycle.d.ts +8 -0
  21. package/dist/auth/second-factor/lifecycle.d.ts.map +1 -0
  22. package/dist/auth/sessions.d.ts.map +1 -1
  23. package/dist/auth/sign-in-code.d.ts +17 -0
  24. package/dist/auth/sign-in-code.d.ts.map +1 -0
  25. package/dist/auth/totp.d.ts +36 -0
  26. package/dist/auth/totp.d.ts.map +1 -0
  27. package/dist/auth/types.d.ts +140 -7
  28. package/dist/auth/types.d.ts.map +1 -1
  29. package/dist/auth/users.d.ts +2 -2
  30. package/dist/auth/users.d.ts.map +1 -1
  31. package/dist/chunks/{index-qwfkhqkk.js → index-06vp9c5r.js} +12 -3
  32. package/dist/chunks/{index-qwfkhqkk.js.map → index-06vp9c5r.js.map} +3 -3
  33. package/dist/chunks/{index-53y1afjz.js → index-5vr13kkb.js} +2 -2
  34. package/dist/chunks/{index-mgh85djb.js → index-c4v27jfr.js} +2 -2
  35. package/dist/chunks/{index-thtyq7a9.js → index-gbwn2tts.js} +2 -2
  36. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  37. package/dist/conformance/index.js +15 -4
  38. package/dist/conformance/index.js.map +3 -3
  39. package/dist/errors/janus-error.d.ts +28 -2
  40. package/dist/errors/janus-error.d.ts.map +1 -1
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +478 -51
  44. package/dist/index.js.map +15 -7
  45. package/dist/permissions/index.js +3 -3
  46. package/docs/README.md +2 -0
  47. package/docs/guide/adapters.md +12 -7
  48. package/docs/guide/email-flows.md +4 -0
  49. package/docs/guide/errors.md +19 -7
  50. package/docs/guide/second-factor.md +571 -0
  51. package/docs/guide/sessions.md +18 -0
  52. package/docs/guide/sign-in-code.md +471 -0
  53. package/docs/guide/users.md +10 -3
  54. package/docs/guide/vocabulary.md +9 -6
  55. package/docs/roadmap.md +39 -54
  56. package/docs/troubleshooting.md +414 -5
  57. package/package.json +1 -1
  58. /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
  59. /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
  60. /package/dist/chunks/{index-thtyq7a9.js.map → index-gbwn2tts.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-thtyq7a9.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",
@@ -213,9 +278,24 @@ function resolveConfig(config, where) {
213
278
  types,
214
279
  tokenTtlMs: {
215
280
  verifyEmail: parseDuration(config.tokens?.verifyEmail ?? "24h", `${where}: tokens.verifyEmail`),
216
- 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`)
217
283
  },
218
- cookie: resolveCookie(config.cookie ?? {}, where)
284
+ cookie: resolveCookie(config.cookie ?? {}, where),
285
+ secondFactor: resolveSecondFactor(config.secondFactor, where)
286
+ };
287
+ }
288
+ function resolveSecondFactor(config, where) {
289
+ if (config === undefined)
290
+ return null;
291
+ const at = `${where}: secondFactor`;
292
+ if (typeof config?.issuer !== "string" || config.issuer.trim() === "") {
293
+ throw new TypeError(`${at}.issuer must name your application — the authenticator app shows it beside the account`);
294
+ }
295
+ return {
296
+ issuer: config.issuer,
297
+ sealer: resolveSealer(config.keys, `${at}.keys`),
298
+ challengeTtlMs: parseDuration(config.challenge ?? "5m", `${at}.challenge`)
219
299
  };
220
300
  }
221
301
  function resolveType(name, type, at, schemaKey) {
@@ -336,6 +416,7 @@ function toUser(record) {
336
416
  emailVerified: record.emailVerifiedAt !== null,
337
417
  active: record.active,
338
418
  hasPassword: record.password !== null,
419
+ hasSecondFactor: record.secondFactor?.confirmedAt != null,
339
420
  version: record.version,
340
421
  createdAt: record.createdAt,
341
422
  updatedAt: record.updatedAt
@@ -499,6 +580,14 @@ async function writeUser(context, user, type, options, where, patchOf) {
499
580
  const patch = await patchOf(record, now);
500
581
  return context.store.users.updateUser(record.id, { ...patch, updatedAt: now }, record.version);
501
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
+ }
502
591
 
503
592
  // src/auth/port/assert-stores.ts
504
593
  var REQUIRED = {
@@ -544,9 +633,9 @@ function assertStores(stores, where) {
544
633
  }
545
634
 
546
635
  // src/auth/secrets.ts
547
- import { createHash, randomBytes as randomBytes2 } from "node:crypto";
636
+ import { createHash, randomBytes as randomBytes3 } from "node:crypto";
548
637
  function mintSecret() {
549
- return randomBytes2(32).toString("base64url");
638
+ return randomBytes3(32).toString("base64url");
550
639
  }
551
640
  function hashSecret(secret) {
552
641
  return createHash("sha256").update(secret).digest("hex");
@@ -570,7 +659,12 @@ async function openSession(context, type, user) {
570
659
  createdAt: now
571
660
  };
572
661
  await context.store.sessions.insertSession(record);
573
- return { user: toUser(user), session: toSession(record), token };
662
+ return {
663
+ status: "signedIn",
664
+ user: toUser(user),
665
+ session: toSession(record),
666
+ token
667
+ };
574
668
  }
575
669
  function sharedApi(context) {
576
670
  const { store, clock, config } = context;
@@ -696,10 +790,373 @@ function headersOf(request) {
696
790
  return request;
697
791
  }
698
792
 
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
+ }
818
+ function refuseUnusable(token, now, where, noun) {
819
+ if (token === null) {
820
+ throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
821
+ operation: where
822
+ });
823
+ }
824
+ if (token.spentAt !== null) {
825
+ throw new TokenError2("TOKEN_SPENT", `${where}: the ${noun} was already used`, { operation: where });
826
+ }
827
+ if (token.expiresAt.getTime() <= now.getTime()) {
828
+ throw new TokenError2("TOKEN_EXPIRED", `${where}: the ${noun} has expired`, {
829
+ operation: where
830
+ });
831
+ }
832
+ }
833
+ var unknownOneTime = (where, noun) => new TokenError2("TOKEN_UNKNOWN", `${where}: no such ${noun}`, {
834
+ operation: where
835
+ });
836
+ async function spendOneTime(context, secret, kind, where, noun) {
837
+ const now = context.clock.now();
838
+ const token = await context.store.tokens.consumeToken(hashSecret(secret), kind, now);
839
+ refuseUnusable(token, now, where, noun);
840
+ return token;
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
+ }
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) {
861
+ const now = context.clock.now();
862
+ const secret = mintSecret();
863
+ const expiresAt = new Date(now.getTime() + token.ttlMs);
864
+ await context.store.tokens.insertToken({
865
+ tokenHash: hashSecret(secret),
866
+ kind: token.kind,
867
+ userId: token.userId,
868
+ address: token.address,
869
+ codeHash: code === null ? null : hashCode(secret, code),
870
+ attempts: 0,
871
+ expiresAt,
872
+ spentAt: null,
873
+ createdAt: now
874
+ });
875
+ return { secret, expiresAt };
876
+ }
877
+
878
+ // src/auth/totp.ts
879
+ import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
880
+ var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
881
+ var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
882
+ function mintTotpSecret() {
883
+ return toBase32(randomBytes4(20));
884
+ }
885
+ function toBase32(bytes) {
886
+ let bits = 0;
887
+ let value = 0;
888
+ let out = "";
889
+ for (const byte of bytes) {
890
+ value = value << 8 | byte;
891
+ bits += 8;
892
+ while (bits >= 5) {
893
+ out += BASE32[value >>> bits - 5 & 31];
894
+ bits -= 5;
895
+ }
896
+ }
897
+ if (bits > 0)
898
+ out += BASE32[value << 5 - bits & 31];
899
+ return out;
900
+ }
901
+ function fromBase32(text) {
902
+ let bits = 0;
903
+ let value = 0;
904
+ const out = [];
905
+ for (const char of text) {
906
+ const index = BASE32.indexOf(char);
907
+ if (index === -1)
908
+ throw new TypeError("totp: the secret is not base32");
909
+ value = value << 5 | index;
910
+ bits += 5;
911
+ if (bits >= 8) {
912
+ out.push(value >>> bits - 8 & 255);
913
+ bits -= 8;
914
+ }
915
+ }
916
+ return Buffer.from(out);
917
+ }
918
+ var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
919
+ function codeAt(secret, step) {
920
+ const counter = Buffer.alloc(8);
921
+ counter.writeBigUInt64BE(BigInt(step));
922
+ const digest = createHmac("sha1", secret).update(counter).digest();
923
+ const offset = digest[19] & 15;
924
+ const binary = digest.readUInt32BE(offset) & 2147483647;
925
+ return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
926
+ }
927
+ function matchStep(secret, code, now, lastStep) {
928
+ if (!/^\d{6}$/.test(code))
929
+ return null;
930
+ const current = stepAt(now);
931
+ let matched = null;
932
+ for (let step = current - TOTP.drift;step <= current + TOTP.drift; step++) {
933
+ const fresh = lastStep === null || step > lastStep;
934
+ if (equalCodes(codeAt(secret, step), code) && fresh && matched === null) {
935
+ matched = step;
936
+ }
937
+ }
938
+ return matched;
939
+ }
940
+ function equalCodes(a, b) {
941
+ let difference = a.length ^ b.length;
942
+ for (let i = 0;i < a.length; i++) {
943
+ difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
944
+ }
945
+ return difference === 0;
946
+ }
947
+ function otpauthUri(issuer, account, secret) {
948
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
949
+ const query = new URLSearchParams({
950
+ secret,
951
+ issuer,
952
+ algorithm: "SHA1",
953
+ digits: String(TOTP.digits),
954
+ period: String(TOTP.stepMs / 1000)
955
+ });
956
+ return `otpauth://totp/${label}?${query}`;
957
+ }
958
+
959
+ // src/auth/second-factor/factor.ts
960
+ var isActive = (factor) => factor !== null && factor.confirmedAt !== null;
961
+ function requireSettings(context, where, why) {
962
+ const configured = context.config.secondFactor;
963
+ if (configured === null) {
964
+ throw new TypeError(`${where}: ${why}, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`);
965
+ }
966
+ return configured;
967
+ }
968
+ function acceptCode(configured, record, factor, code, now, where) {
969
+ const { plain, keyId } = unseal(configured.sealer, factor.secret, record.id, where);
970
+ const step = matchStep(fromBase32(plain), code, now, factor.lastStep);
971
+ if (step === null)
972
+ return null;
973
+ return {
974
+ ...factor,
975
+ lastStep: step,
976
+ secret: keyId === configured.sealer.sealWith ? factor.secret : seal(configured.sealer, plain, record.id)
977
+ };
978
+ }
979
+
980
+ // src/auth/second-factor/challenge.ts
981
+ function challengeFlows(context, type, at) {
982
+ const { store, clock } = context;
983
+ const spend = (challenge, where) => spendOneTime(context, challenge, "secondFactor", where, "challenge");
984
+ return {
985
+ async issue(record, where) {
986
+ const configured = requireSettings(context, where, "the user's second factor is active");
987
+ const { secret, expiresAt } = await issueOneTime(context, {
988
+ kind: "secondFactor",
989
+ userId: record.id,
990
+ address: "",
991
+ ttlMs: configured.challengeTtlMs
992
+ });
993
+ return { status: "secondFactor", challenge: secret, expiresAt };
994
+ },
995
+ async confirm(challenge, code) {
996
+ const where = at("secondFactor.confirm");
997
+ const configured = requireSettings(context, where, "a second factor is being confirmed");
998
+ const secret = String(challenge);
999
+ const { token, attemptsLeft } = await countCodeAttempt(context, secret, "secondFactor", where, type.name);
1000
+ const record = await findRecord(context, token.userId, type.name);
1001
+ if (record === null)
1002
+ throw unknownOneTime(where, "challenge");
1003
+ if (!record.active) {
1004
+ await spend(secret, where);
1005
+ throw new UserInactiveError2(`${where}: the user is inactive`, {
1006
+ userId: record.id,
1007
+ userType: type.name
1008
+ });
1009
+ }
1010
+ if (!isActive(record.secondFactor)) {
1011
+ await spend(secret, where);
1012
+ 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 });
1013
+ }
1014
+ const now = clock.now();
1015
+ const accepted = acceptCode(configured, record, record.secondFactor, String(code), now, where);
1016
+ if (accepted === null) {
1017
+ if (attemptsLeft === 0) {
1018
+ await burnOneTime(context, secret, "secondFactor");
1019
+ }
1020
+ throw codeInvalid(where, record.id, type.name, attemptsLeft);
1021
+ }
1022
+ const written = await store.users.updateUser(record.id, { secondFactor: accepted, updatedAt: now }, record.version);
1023
+ await spend(secret, where);
1024
+ return openSession(context, type, written);
1025
+ }
1026
+ };
1027
+ }
1028
+
1029
+ // src/auth/second-factor/lifecycle.ts
1030
+ function lifecycleFlows(context, type, at) {
1031
+ const accountOf = (record, where) => {
1032
+ if (type.password === null) {
1033
+ throw new TypeError(`${where}: the ${type.name} type does not sign in with a password, so it has no second factor`);
1034
+ }
1035
+ return String(record.fields[type.password.login]);
1036
+ };
1037
+ const refuse = (code, message, where, record) => new SecondFactorError2(code, `${where}: ${message}`, {
1038
+ operation: where,
1039
+ userId: record.id,
1040
+ userType: type.name
1041
+ });
1042
+ return {
1043
+ async enroll(user, options) {
1044
+ const where = at("secondFactor.enroll");
1045
+ const configured = requireSettings(context, where, "a second factor is being enrolled");
1046
+ const secret = mintTotpSecret();
1047
+ const written = await writeUser(context, user, type, options, where, (record) => {
1048
+ if (isActive(record.secondFactor)) {
1049
+ throw refuse("SECOND_FACTOR_ACTIVE", "the user's second factor is active — disable it first", where, record);
1050
+ }
1051
+ accountOf(record, where);
1052
+ return {
1053
+ secondFactor: {
1054
+ method: "totp",
1055
+ secret: seal(configured.sealer, secret, record.id),
1056
+ confirmedAt: null,
1057
+ lastStep: null
1058
+ }
1059
+ };
1060
+ });
1061
+ return {
1062
+ secret,
1063
+ uri: otpauthUri(configured.issuer, accountOf(written, where), secret)
1064
+ };
1065
+ },
1066
+ async activate(user, code, options) {
1067
+ const where = at("secondFactor.activate");
1068
+ const configured = requireSettings(context, where, "a second factor is being activated");
1069
+ const written = await writeUser(context, user, type, options, where, (record, now) => {
1070
+ const factor = record.secondFactor;
1071
+ if (factor === null) {
1072
+ throw refuse("SECOND_FACTOR_NOT_ENROLLED", "the user has no second factor waiting — call enroll first", where, record);
1073
+ }
1074
+ if (isActive(factor)) {
1075
+ throw refuse("SECOND_FACTOR_ACTIVE", "the user's second factor is already active", where, record);
1076
+ }
1077
+ const accepted = acceptCode(configured, record, factor, String(code), now, where);
1078
+ if (accepted === null)
1079
+ throw codeInvalid(where, record.id, type.name);
1080
+ return { secondFactor: { ...accepted, confirmedAt: now } };
1081
+ });
1082
+ return toUser(written);
1083
+ },
1084
+ async disable(user, options) {
1085
+ const where = at("secondFactor.disable");
1086
+ return toUser(await writeUser(context, user, type, options, where, () => ({
1087
+ secondFactor: null
1088
+ })));
1089
+ }
1090
+ };
1091
+ }
1092
+
1093
+ // src/auth/second-factor/flows.ts
1094
+ function secondFactorFlows(context, type, at) {
1095
+ const challenges = challengeFlows(context, type, at);
1096
+ const api = {
1097
+ ...lifecycleFlows(context, type, at),
1098
+ confirm: challenges.confirm
1099
+ };
1100
+ return {
1101
+ api,
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
+ }
1151
+ };
1152
+ }
1153
+
699
1154
  // src/auth/users.ts
700
1155
  function typeApi(context, type) {
701
1156
  const { store, clock } = context;
702
1157
  const at = (operation) => context.config.single ? operation : `${type.name}.${operation}`;
1158
+ const secondFactor = secondFactorFlows(context, type, at);
1159
+ const signInCode = signInCodeFlows(context, type, at, secondFactor.finish);
703
1160
  const byLogin = async (login) => isStorable(login) ? store.users.findUserByLogin(type.name, login) : null;
704
1161
  const insert = async (input, where) => {
705
1162
  const { password, active, ...rest } = input ?? {};
@@ -729,33 +1186,11 @@ function typeApi(context, type) {
729
1186
  });
730
1187
  };
731
1188
  const redeem = async (kind, secret, where) => {
732
- const now = clock.now();
733
- const token = await store.tokens.consumeToken(hashSecret(secret), kind, now);
734
- if (token === null) {
735
- throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such token`, {
736
- operation: where
737
- });
738
- }
739
- if (token.spentAt !== null) {
740
- throw new TokenError2("TOKEN_SPENT", `${where}: the token was already used`, {
741
- operation: where
742
- });
743
- }
744
- if (token.expiresAt.getTime() <= now.getTime()) {
745
- throw new TokenError2("TOKEN_EXPIRED", `${where}: the token has expired`, {
746
- operation: where
747
- });
748
- }
1189
+ const token = await spendOneTime(context, secret, kind, where, "token");
749
1190
  const user = await findRecord(context, token.userId, type.name);
750
- if (user === null) {
751
- throw new TokenError2("TOKEN_UNKNOWN", `${where}: no such token`, {
752
- operation: where
753
- });
754
- }
755
- const email = emailOf(type, user.fields);
756
- if (email === null || normalizeEmail(email) !== normalizeEmail(token.address)) {
757
- 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 });
758
- }
1191
+ if (user === null)
1192
+ throw unknownOneTime(where, "token");
1193
+ refuseStale(type, user, token, where, "token");
759
1194
  return { token, user };
760
1195
  };
761
1196
  const issue = async (kind, user, where) => {
@@ -767,19 +1202,11 @@ function typeApi(context, type) {
767
1202
  operation: where
768
1203
  });
769
1204
  }
770
- const now = clock.now();
771
- const secret = mintSecret();
772
- const expiresAt = new Date(now.getTime() + context.config.tokenTtlMs[kind]);
773
- await store.tokens.insertToken({
774
- tokenHash: hashSecret(secret),
1205
+ const { secret, expiresAt } = await issueOneTime(context, {
775
1206
  kind,
776
1207
  userId: user.id,
777
1208
  address: email,
778
- codeHash: null,
779
- attempts: 0,
780
- expiresAt,
781
- spentAt: null,
782
- createdAt: now
1209
+ ttlMs: context.config.tokenTtlMs[kind]
783
1210
  });
784
1211
  return { token: secret, email, expiresAt };
785
1212
  };
@@ -876,7 +1303,7 @@ function typeApi(context, type) {
876
1303
  userType: type.name
877
1304
  });
878
1305
  }
879
- return openSession(context, type, await rehashed(context, record, String(password)));
1306
+ return secondFactor.finish(await rehashed(context, record, String(password)), where);
880
1307
  },
881
1308
  async findByLogin(login) {
882
1309
  const rule = passwordRule(at("findByLogin"));
@@ -913,6 +1340,8 @@ function typeApi(context, type) {
913
1340
  };
914
1341
  }));
915
1342
  },
1343
+ secondFactor: secondFactor.api,
1344
+ signInCode,
916
1345
  verifyEmail: {
917
1346
  async send(user) {
918
1347
  const where = at("verifyEmail.send");
@@ -931,12 +1360,9 @@ function typeApi(context, type) {
931
1360
  async request(email) {
932
1361
  const where = at("resetPassword.request");
933
1362
  passwordRule(where);
934
- const wanted = normalizeEmail(String(email));
935
- const record = await byLogin(wanted);
936
- const held = record === null ? null : emailOf(type, record.fields);
937
- if (record === null || held === null || normalizeEmail(held) !== wanted) {
1363
+ const record = await holderOfEmail(context, type, String(email));
1364
+ if (record === null)
938
1365
  return null;
939
- }
940
1366
  const issued = await issue("resetPassword", record, where);
941
1367
  return { ...issued, user: toUser(record) };
942
1368
  },
@@ -998,6 +1424,7 @@ export {
998
1424
  MAX_PAGE_SIZE2 as MAX_PAGE_SIZE,
999
1425
  NotFoundError2 as NotFoundError,
1000
1426
  PermissionDepthError2 as PermissionDepthError,
1427
+ SecondFactorError2 as SecondFactorError,
1001
1428
  StoreConflict2 as StoreConflict,
1002
1429
  StoreFailure2 as StoreFailure,
1003
1430
  TokenError2 as TokenError,
@@ -1028,5 +1455,5 @@ export {
1028
1455
  systemClock
1029
1456
  };
1030
1457
 
1031
- //# debugId=0C2151B9E5FBCB0864756E2164756E21
1458
+ //# debugId=9A2F9DDAD478673A64756E2164756E21
1032
1459
  //# sourceMappingURL=index.js.map