@absolutejs/auth 0.75.3 → 0.76.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
@@ -6185,7 +6185,45 @@ var createLockoutGuard = ({
6185
6185
  });
6186
6186
 
6187
6187
  // src/mfa/types.ts
6188
- var isMfaEnrolled = (enrollment) => enrollment !== undefined && (enrollment.totpVerified || enrollment.smsVerified || enrollment.backupCodeHashes.length > 0);
6188
+ var LEGACY_SMS_FACTOR_ID = "legacy-sms";
6189
+ var LEGACY_TOTP_FACTOR_ID = "legacy-totp";
6190
+ var getMfaFactors = (enrollment) => {
6191
+ if (enrollment.factors !== undefined)
6192
+ return enrollment.factors;
6193
+ const factors = [];
6194
+ if (enrollment.totpSecretCiphertext) {
6195
+ factors.push({
6196
+ id: LEGACY_TOTP_FACTOR_ID,
6197
+ label: "Authenticator app",
6198
+ secretCiphertext: enrollment.totpSecretCiphertext,
6199
+ type: "totp",
6200
+ verified: enrollment.totpVerified
6201
+ });
6202
+ }
6203
+ if (enrollment.smsPhone) {
6204
+ factors.push({
6205
+ id: LEGACY_SMS_FACTOR_ID,
6206
+ label: "Text message",
6207
+ phone: enrollment.smsPhone,
6208
+ type: "sms",
6209
+ verified: enrollment.smsVerified
6210
+ });
6211
+ }
6212
+ return factors;
6213
+ };
6214
+ var isMfaEnrolled = (enrollment) => enrollment !== undefined && (getMfaFactors(enrollment).some((factor) => factor.verified) || enrollment.backupCodeHashes.length > 0);
6215
+ var withMfaFactors = (enrollment, factors) => {
6216
+ const firstSms = factors.find((factor) => factor.type === "sms");
6217
+ const firstTotp = factors.find((factor) => factor.type === "totp");
6218
+ return {
6219
+ ...enrollment,
6220
+ factors,
6221
+ smsPhone: firstSms?.phone,
6222
+ smsVerified: firstSms?.verified ?? false,
6223
+ totpSecretCiphertext: firstTotp?.secretCiphertext,
6224
+ totpVerified: firstTotp?.verified ?? false
6225
+ };
6226
+ };
6189
6227
 
6190
6228
  // src/mfa/gate.ts
6191
6229
  var createMfaGate = ({ getUserId, mfaStore }) => async (user) => isMfaEnrolled(await mfaStore.getEnrollment(getUserId(user)));
@@ -6401,6 +6439,8 @@ var hasRecentAuthentication = (session, maxAgeMs, now = Date.now()) => session.a
6401
6439
  // src/mfa/sms.ts
6402
6440
  var DECIMAL_RADIX2 = 10;
6403
6441
  var MASK_VISIBLE_DIGITS = 4;
6442
+ var FACTOR_LABEL_MAX_LENGTH = 80;
6443
+ var DEFAULT_SMS_LABEL = "Text message";
6404
6444
  var E164_PATTERN = /^\+[1-9]\d{7,14}$/u;
6405
6445
  var generateNumericCode = (length) => {
6406
6446
  const bytes = new Uint8Array(length);
@@ -6574,10 +6614,13 @@ var mfaSmsRoutes = ({
6574
6614
  smsSetupRoute = "/auth/mfa/sms/setup",
6575
6615
  smsVerifyRoute = "/auth/mfa/sms/verify"
6576
6616
  }) => new Elysia18().use(sessionStore()).post(smsSetupRoute, {
6577
- body: t12.Object({ phone: t12.String() }),
6617
+ body: t12.Object({
6618
+ label: t12.Optional(t12.String({ maxLength: FACTOR_LABEL_MAX_LENGTH })),
6619
+ phone: t12.String()
6620
+ }),
6578
6621
  cookie: t12.Cookie({ user_session_id: userSessionIdTypebox })
6579
6622
  }, async ({
6580
- body: { phone },
6623
+ body: { label, phone },
6581
6624
  cookie: { user_session_id },
6582
6625
  status,
6583
6626
  store: { session }
@@ -6605,21 +6648,35 @@ var mfaSmsRoutes = ({
6605
6648
  if (existing?.smsCodeSentAt !== undefined && now - existing.smsCodeSentAt < smsResendCooldownMs) {
6606
6649
  return status("Too Many Requests", "SMS resend cooldown active");
6607
6650
  }
6651
+ const base = existing ?? {
6652
+ backupCodeHashes: [],
6653
+ createdAt: now,
6654
+ smsVerified: false,
6655
+ totpVerified: false,
6656
+ updatedAt: now,
6657
+ userId
6658
+ };
6659
+ const factor = {
6660
+ id: crypto.randomUUID(),
6661
+ label: label?.trim() || DEFAULT_SMS_LABEL,
6662
+ phone,
6663
+ type: "sms",
6664
+ verified: false
6665
+ };
6666
+ const factors = [
6667
+ ...getMfaFactors(base).filter((existingFactor) => existingFactor.type !== "sms" || existingFactor.verified),
6668
+ factor
6669
+ ];
6608
6670
  try {
6609
6671
  await issueAndStoreSmsCode({
6610
6672
  codeLength: smsCodeLength,
6611
6673
  enrollment: {
6612
- backupCodeHashes: existing?.backupCodeHashes ?? [],
6613
- createdAt: existing?.createdAt ?? now,
6614
- lastUsedAt: existing?.lastUsedAt,
6674
+ ...withMfaFactors(base, factors),
6615
6675
  smsFailedAttempts: 0,
6676
+ smsPendingFactorId: factor.id,
6616
6677
  smsPhone: phone,
6617
- smsVerified: false,
6618
- totpFailedAttempts: existing?.totpFailedAttempts ?? 0,
6619
- totpSecretCiphertext: existing?.totpSecretCiphertext,
6620
- totpVerified: existing?.totpVerified ?? false,
6621
6678
  updatedAt: now,
6622
- userId
6679
+ userId: base.userId
6623
6680
  },
6624
6681
  mfaStore,
6625
6682
  onSendSmsCode,
@@ -6636,12 +6693,18 @@ var mfaSmsRoutes = ({
6636
6693
  throw error;
6637
6694
  return status(mapped.status, mapped.message);
6638
6695
  }
6639
- return status("OK", { phone: maskPhone(phone) });
6696
+ return status("OK", {
6697
+ factorId: factor.id,
6698
+ phone: maskPhone(phone)
6699
+ });
6640
6700
  }).post(smsVerifyRoute, {
6641
- body: t12.Object({ code: t12.String() }),
6701
+ body: t12.Object({
6702
+ code: t12.String(),
6703
+ factorId: t12.Optional(t12.String())
6704
+ }),
6642
6705
  cookie: t12.Cookie({ user_session_id: userSessionIdTypebox })
6643
6706
  }, async ({
6644
- body: { code },
6707
+ body: { code, factorId },
6645
6708
  cookie: { user_session_id },
6646
6709
  status,
6647
6710
  store: { session }
@@ -6662,6 +6725,12 @@ var mfaSmsRoutes = ({
6662
6725
  if (!enrollment?.smsPhone) {
6663
6726
  return status("Bad Request", "No SMS enrollment in progress");
6664
6727
  }
6728
+ const factors = getMfaFactors(enrollment);
6729
+ const pendingFactorId = factorId ?? enrollment.smsPendingFactorId;
6730
+ const pendingFactor = factors.find((candidate) => candidate.type === "sms" && (candidate.id === pendingFactorId || pendingFactorId === undefined && candidate.phone === enrollment.smsPhone));
6731
+ if (!pendingFactor || pendingFactor.type !== "sms") {
6732
+ return status("Bad Request", "No SMS enrollment in progress");
6733
+ }
6665
6734
  const localCodeHash = enrollment.smsPendingCodeHash;
6666
6735
  const challengeId = enrollment.smsChallengeId;
6667
6736
  const localCodeExpiresAt = enrollment.smsPendingCodeExpiresAt;
@@ -6689,7 +6758,7 @@ var mfaSmsRoutes = ({
6689
6758
  purpose: "mfa_enrollment",
6690
6759
  reference: providerReference,
6691
6760
  subject: userId,
6692
- to: enrollment.smsPhone
6761
+ to: pendingFactor.phone
6693
6762
  });
6694
6763
  if (checked.error !== undefined) {
6695
6764
  return status(checked.error.status, checked.error.message);
@@ -6705,8 +6774,10 @@ var mfaSmsRoutes = ({
6705
6774
  });
6706
6775
  return status(providerResult?.status === "max_attempts_reached" || attempts === undefined ? "Too Many Requests" : "Bad Request", providerResult?.status === "expired" ? "SMS code expired" : "Invalid SMS code");
6707
6776
  }
6777
+ const verifiedFactors = factors.map((candidate) => candidate.id === pendingFactor.id ? { ...candidate, verified: true } : candidate);
6708
6778
  const completed = await mfaStore.completeSmsChallenge({
6709
6779
  challengeId,
6780
+ factors: verifiedFactors,
6710
6781
  smsVerified: true,
6711
6782
  userId
6712
6783
  });
@@ -6736,15 +6807,50 @@ var mfaChallenge = ({
6736
6807
  smsMaxAttempts = DEFAULT_SMS_MAX_ATTEMPTS,
6737
6808
  smsResendCooldownMs = DEFAULT_SMS_RESEND_COOLDOWN_MS,
6738
6809
  totpMaxAttempts = DEFAULT_TOTP_MAX_ATTEMPTS
6739
- }) => new Elysia19().use(sessionStore()).post(challengeRoute, {
6810
+ }) => new Elysia19().use(sessionStore()).get(challengeRoute, { cookie: t13.Cookie({ user_session_id: userSessionIdTypebox }) }, async ({
6811
+ cookie: { user_session_id },
6812
+ status,
6813
+ store: { unregisteredSession }
6814
+ }) => {
6815
+ const compatibilityLayer = await createSessionCompatibilityLayer({
6816
+ authSessionStore,
6817
+ userSessionId: user_session_id.value
6818
+ });
6819
+ const challengeUnregistered = authSessionStore ? compatibilityLayer.unregisteredSession : unregisteredSession;
6820
+ const pendingId = user_session_id.value;
6821
+ const pending = pendingId ? challengeUnregistered[pendingId] : undefined;
6822
+ if (!pending) {
6823
+ return status("Unauthorized", "No MFA challenge in progress");
6824
+ }
6825
+ const user = await getChallengeUser(pending.userIdentity ?? {});
6826
+ const enrollment = user ? await mfaStore.getEnrollment(getUserId(user)) : undefined;
6827
+ if (!enrollment) {
6828
+ return status("Unauthorized", "No MFA challenge in progress");
6829
+ }
6830
+ const response = {
6831
+ backupCodesAvailable: enrollment.backupCodeHashes.length > 0,
6832
+ factors: getMfaFactors(enrollment).filter((factor) => factor.verified).map((factor) => ({
6833
+ id: factor.id,
6834
+ label: factor.label,
6835
+ phone: factor.type === "sms" ? maskPhone(factor.phone) : null,
6836
+ type: factor.type
6837
+ }))
6838
+ };
6839
+ return status("OK", response);
6840
+ }).post(challengeRoute, {
6740
6841
  body: t13.Object({
6741
6842
  action: t13.Optional(t13.Union([t13.Literal("send"), t13.Literal("verify")])),
6742
6843
  code: t13.Optional(t13.String()),
6743
- factor: t13.Optional(t13.Literal("sms"))
6844
+ factor: t13.Optional(t13.Union([
6845
+ t13.Literal("backup_codes"),
6846
+ t13.Literal("sms"),
6847
+ t13.Literal("totp")
6848
+ ])),
6849
+ factorId: t13.Optional(t13.String())
6744
6850
  }),
6745
6851
  cookie: t13.Cookie({ user_session_id: userSessionIdTypebox })
6746
6852
  }, async ({
6747
- body: { action, code, factor },
6853
+ body: { action, code, factor, factorId },
6748
6854
  cookie: { user_session_id },
6749
6855
  status,
6750
6856
  store: { session, unregisteredSession }
@@ -6765,6 +6871,7 @@ var mfaChallenge = ({
6765
6871
  if (!user || !enrollment) {
6766
6872
  return status("Unauthorized", "No MFA challenge in progress");
6767
6873
  }
6874
+ const factors = getMfaFactors(enrollment);
6768
6875
  const promote = async () => {
6769
6876
  delete challengeUnregistered[pendingId];
6770
6877
  const userSessionId = crypto.randomUUID();
@@ -6787,10 +6894,18 @@ var mfaChallenge = ({
6787
6894
  if (enrollment.smsCodeSentAt !== undefined && Date.now() - enrollment.smsCodeSentAt < smsResendCooldownMs) {
6788
6895
  return status("Too Many Requests", "SMS resend cooldown active");
6789
6896
  }
6897
+ const smsFactor = factors.find((candidate) => candidate.type === "sms" && candidate.verified && (candidate.id === factorId || factorId === undefined));
6898
+ if (!smsFactor || smsFactor.type !== "sms") {
6899
+ return status("Bad Request", "SMS factor not found");
6900
+ }
6790
6901
  try {
6791
6902
  await issueAndStoreSmsCode({
6792
6903
  codeLength: smsCodeLength,
6793
- enrollment,
6904
+ enrollment: {
6905
+ ...withMfaFactors(enrollment, factors),
6906
+ smsPendingFactorId: smsFactor.id,
6907
+ smsPhone: smsFactor.phone
6908
+ },
6794
6909
  mfaStore,
6795
6910
  onSendSmsCode,
6796
6911
  previousEnrollment: enrollment,
@@ -6806,10 +6921,19 @@ var mfaChallenge = ({
6806
6921
  throw error;
6807
6922
  return status(mapped.status, mapped.message);
6808
6923
  }
6809
- return status("OK", { status: "sent" });
6924
+ return status("OK", {
6925
+ factorId: smsFactor.id,
6926
+ phone: maskPhone(smsFactor.phone),
6927
+ status: "sent"
6928
+ });
6810
6929
  };
6811
6930
  const runSmsChallenge = async () => {
6812
- if (!enrollment.smsVerified || !enrollment.smsPhone) {
6931
+ if (action !== "send" && factorId !== undefined && enrollment.smsPendingFactorId !== undefined && factorId !== enrollment.smsPendingFactorId) {
6932
+ return status("Bad Request", "No SMS code in progress");
6933
+ }
6934
+ const selectedFactorId = enrollment.smsPendingFactorId ?? factorId;
6935
+ const smsFactor = factors.find((candidate) => candidate.type === "sms" && candidate.verified && (candidate.id === selectedFactorId || selectedFactorId === undefined));
6936
+ if (!smsFactor || smsFactor.type !== "sms") {
6813
6937
  return status("Unauthorized", "No MFA challenge in progress");
6814
6938
  }
6815
6939
  if (action === "send")
@@ -6848,7 +6972,7 @@ var mfaChallenge = ({
6848
6972
  purpose: "mfa_challenge",
6849
6973
  reference: providerReference,
6850
6974
  subject: getUserId(user),
6851
- to: enrollment.smsPhone
6975
+ to: smsFactor.phone
6852
6976
  });
6853
6977
  if (checked.error !== undefined) {
6854
6978
  return status(checked.error.status, checked.error.message);
@@ -6870,6 +6994,7 @@ var mfaChallenge = ({
6870
6994
  }
6871
6995
  const completed = await mfaStore.completeSmsChallenge({
6872
6996
  challengeId,
6997
+ factors,
6873
6998
  lastUsedAt: Date.now(),
6874
6999
  smsVerified: true,
6875
7000
  userId: getUserId(user)
@@ -6891,11 +7016,14 @@ var mfaChallenge = ({
6891
7016
  });
6892
7017
  return status("Unauthorized", "Too many attempts");
6893
7018
  }
6894
- const totpValid = enrollment.totpVerified && enrollment.totpSecretCiphertext ? await verifyTotp({
6895
- secret: await decryptTotpSecret(enrollment.totpSecretCiphertext, encryptionKey),
7019
+ const totpFactors = factors.filter((candidate) => candidate.type === "totp" && candidate.verified && (candidate.id === factorId || factorId === undefined));
7020
+ const totpChecks = factor === "backup_codes" ? [] : await Promise.all(totpFactors.map(async (candidate) => verifyTotp({
7021
+ secret: await decryptTotpSecret(candidate.secretCiphertext, encryptionKey),
6896
7022
  token: code
6897
- }) : false;
6898
- const remainingBackupHashes = totpValid ? undefined : await consumeBackupCode(code, enrollment.backupCodeHashes);
7023
+ })));
7024
+ const totpValid = totpChecks.some(Boolean);
7025
+ const mayUseBackupCode = factor === "backup_codes" || factor === undefined && factorId === undefined;
7026
+ const remainingBackupHashes = totpValid || !mayUseBackupCode ? undefined : await consumeBackupCode(code, enrollment.backupCodeHashes);
6899
7027
  if (!totpValid && remainingBackupHashes === undefined) {
6900
7028
  await mfaStore.saveEnrollment({
6901
7029
  ...enrollment,
@@ -6927,6 +7055,12 @@ var maskPhone2 = (phone) => {
6927
7055
  const suffix = phone.slice(-visibleDigits);
6928
7056
  return `${"*".repeat(Math.max(0, phone.length - visibleDigits))}${suffix}`;
6929
7057
  };
7058
+ var publicFactors = (enrollment) => enrollment ? getMfaFactors(enrollment).filter((factor) => factor.verified).map((factor) => ({
7059
+ id: factor.id,
7060
+ label: factor.label,
7061
+ phone: factor.type === "sms" ? maskPhone2(factor.phone) : null,
7062
+ type: factor.type
7063
+ })) : [];
6930
7064
  var mfaManagementRoutes = ({
6931
7065
  authSessionStore,
6932
7066
  getUserId,
@@ -6950,6 +7084,7 @@ var mfaManagementRoutes = ({
6950
7084
  const response = {
6951
7085
  backupCodesRemaining: enrollment?.backupCodeHashes.length ?? 0,
6952
7086
  enabled: isMfaEnrolled(enrollment),
7087
+ factors: publicFactors(enrollment),
6953
7088
  smsBackup: {
6954
7089
  enabled: enrollment?.smsVerified ?? false,
6955
7090
  phone: maskPhone2(enrollment?.smsPhone)
@@ -6975,11 +7110,54 @@ var mfaManagementRoutes = ({
6975
7110
  }
6976
7111
  await mfaStore.removeEnrollment(getUserId(userSession.user));
6977
7112
  return status("OK", { status: "disabled" });
7113
+ }).delete(`${managementRoute}/factors/:factorId`, {
7114
+ cookie: t14.Cookie({ user_session_id: userSessionIdTypebox }),
7115
+ params: t14.Object({ factorId: t14.String() })
7116
+ }, async ({
7117
+ cookie: { user_session_id },
7118
+ params: { factorId },
7119
+ status,
7120
+ store: { session }
7121
+ }) => {
7122
+ const userSession = await loadSessionFromSource({
7123
+ authSessionStore,
7124
+ session,
7125
+ userSessionId: user_session_id.value
7126
+ });
7127
+ if (!userSession) {
7128
+ return status("Unauthorized", "Authentication required");
7129
+ }
7130
+ if (!hasRecentAuthentication(userSession, managementAuthMaxAgeMs)) {
7131
+ return status("Unauthorized", "Recent authentication required");
7132
+ }
7133
+ const userId = getUserId(userSession.user);
7134
+ const enrollment = await mfaStore.getEnrollment(userId);
7135
+ if (!enrollment) {
7136
+ return status("Not Found", "MFA factor not found");
7137
+ }
7138
+ const factors = getMfaFactors(enrollment);
7139
+ if (!factors.some((factor) => factor.id === factorId)) {
7140
+ return status("Not Found", "MFA factor not found");
7141
+ }
7142
+ const remaining = factors.filter((factor) => factor.id !== factorId);
7143
+ if (remaining.length === 0) {
7144
+ await mfaStore.removeEnrollment(userId);
7145
+ } else {
7146
+ const hasVerifiedFactor = remaining.some((factor) => factor.verified);
7147
+ await mfaStore.saveEnrollment(withMfaFactors({
7148
+ ...enrollment,
7149
+ backupCodeHashes: hasVerifiedFactor ? enrollment.backupCodeHashes : [],
7150
+ updatedAt: Date.now()
7151
+ }, remaining));
7152
+ }
7153
+ return status("OK", { status: "removed" });
6978
7154
  });
6979
7155
 
6980
7156
  // src/mfa/totp.ts
6981
7157
  init_crypto();
6982
7158
  import { Elysia as Elysia21, t as t15 } from "elysia";
7159
+ var FACTOR_LABEL_MAX_LENGTH2 = 80;
7160
+ var DEFAULT_TOTP_LABEL = "Authenticator app";
6983
7161
  var mfaTotpRoutes = ({
6984
7162
  authSessionStore,
6985
7163
  backupCodeCount = DEFAULT_BACKUP_CODE_COUNT,
@@ -6991,7 +7169,13 @@ var mfaTotpRoutes = ({
6991
7169
  onMfaEnrolled,
6992
7170
  totpSetupRoute = "/auth/mfa/totp/setup",
6993
7171
  totpVerifyRoute = "/auth/mfa/totp/verify"
6994
- }) => new Elysia21().use(sessionStore()).post(totpSetupRoute, { cookie: t15.Cookie({ user_session_id: userSessionIdTypebox }) }, async ({
7172
+ }) => new Elysia21().use(sessionStore()).post(totpSetupRoute, {
7173
+ body: t15.Object({
7174
+ label: t15.Optional(t15.String({ maxLength: FACTOR_LABEL_MAX_LENGTH2 }))
7175
+ }),
7176
+ cookie: t15.Cookie({ user_session_id: userSessionIdTypebox })
7177
+ }, async ({
7178
+ body: { label },
6995
7179
  cookie: { user_session_id },
6996
7180
  status,
6997
7181
  store: { session }
@@ -7011,20 +7195,25 @@ var mfaTotpRoutes = ({
7011
7195
  const secret = generateTotpSecret();
7012
7196
  const existing = await mfaStore.getEnrollment(userId);
7013
7197
  const now = Date.now();
7014
- await mfaStore.saveEnrollment({
7015
- backupCodeHashes: existing?.backupCodeHashes ?? [],
7016
- createdAt: existing?.createdAt ?? now,
7017
- smsFailedAttempts: existing?.smsFailedAttempts,
7018
- smsPendingCodeExpiresAt: existing?.smsPendingCodeExpiresAt,
7019
- smsPendingCodeHash: existing?.smsPendingCodeHash,
7020
- smsPhone: existing?.smsPhone,
7021
- smsVerified: existing?.smsVerified ?? false,
7022
- totpSecretCiphertext: await encryptTotpSecret(secret, encryptionKey),
7198
+ const base = existing ?? {
7199
+ backupCodeHashes: [],
7200
+ createdAt: now,
7201
+ smsVerified: false,
7023
7202
  totpVerified: false,
7024
7203
  updatedAt: now,
7025
7204
  userId
7026
- });
7205
+ };
7206
+ const factor = {
7207
+ id: crypto.randomUUID(),
7208
+ label: label?.trim() || DEFAULT_TOTP_LABEL,
7209
+ secretCiphertext: await encryptTotpSecret(secret, encryptionKey),
7210
+ type: "totp",
7211
+ verified: false
7212
+ };
7213
+ const factors = getMfaFactors(base).filter((existingFactor) => existingFactor.type !== "totp" || existingFactor.verified);
7214
+ await mfaStore.saveEnrollment(withMfaFactors({ ...base, updatedAt: now }, [...factors, factor]));
7027
7215
  return status("OK", {
7216
+ factorId: factor.id,
7028
7217
  secret,
7029
7218
  uri: createTotpKeyUri({
7030
7219
  accountName: userId,
@@ -7033,10 +7222,13 @@ var mfaTotpRoutes = ({
7033
7222
  })
7034
7223
  });
7035
7224
  }).post(totpVerifyRoute, {
7036
- body: t15.Object({ code: t15.String() }),
7225
+ body: t15.Object({
7226
+ code: t15.String(),
7227
+ factorId: t15.Optional(t15.String())
7228
+ }),
7037
7229
  cookie: t15.Cookie({ user_session_id: userSessionIdTypebox })
7038
7230
  }, async ({
7039
- body: { code },
7231
+ body: { code, factorId },
7040
7232
  cookie: { user_session_id },
7041
7233
  status,
7042
7234
  store: { session }
@@ -7054,21 +7246,26 @@ var mfaTotpRoutes = ({
7054
7246
  }
7055
7247
  const userId = getUserId(userSession.user);
7056
7248
  const enrollment = await mfaStore.getEnrollment(userId);
7057
- if (!enrollment?.totpSecretCiphertext) {
7249
+ if (!enrollment) {
7250
+ return status("Bad Request", "No TOTP enrollment in progress");
7251
+ }
7252
+ const factors = getMfaFactors(enrollment);
7253
+ const factor = factors.find((candidate) => candidate.type === "totp" && (candidate.id === factorId || factorId === undefined && !candidate.verified));
7254
+ if (!factor || factor.type !== "totp") {
7058
7255
  return status("Bad Request", "No TOTP enrollment in progress");
7059
7256
  }
7060
- const secret = await decryptTotpSecret(enrollment.totpSecretCiphertext, encryptionKey);
7257
+ const secret = await decryptTotpSecret(factor.secretCiphertext, encryptionKey);
7061
7258
  const valid = await verifyTotp({ secret, token: code });
7062
7259
  if (!valid) {
7063
7260
  return status("Bad Request", "Invalid TOTP code");
7064
7261
  }
7065
7262
  const { codes, hashes } = await generateBackupCodes(backupCodeCount);
7066
- await mfaStore.saveEnrollment({
7263
+ const verifiedFactors = factors.map((candidate) => candidate.id === factor.id ? { ...candidate, verified: true } : candidate);
7264
+ await mfaStore.saveEnrollment(withMfaFactors({
7067
7265
  ...enrollment,
7068
7266
  backupCodeHashes: hashes,
7069
- totpVerified: true,
7070
7267
  updatedAt: Date.now()
7071
- });
7268
+ }, verifiedFactors));
7072
7269
  await onMfaEnrolled?.({ userId });
7073
7270
  return status("OK", { backupCodes: codes });
7074
7271
  });
@@ -34782,23 +34979,33 @@ var ensureReadable = async (ciphertext, key, userId) => {
34782
34979
  }
34783
34980
  };
34784
34981
  var planEnrollment = async (enrollment, oldKey, newKey) => {
34785
- const ciphertext = enrollment.totpSecretCiphertext;
34786
- if (ciphertext === undefined || ciphertext.length === 0) {
34982
+ const factors = getMfaFactors(enrollment);
34983
+ const totpFactors = factors.filter((factor) => factor.type === "totp");
34984
+ if (totpFactors.length === 0) {
34787
34985
  const plan2 = { kind: "skip" };
34788
34986
  return plan2;
34789
34987
  }
34790
- const secret = await tryDecrypt(ciphertext, oldKey);
34791
- if (secret === undefined) {
34792
- await ensureReadable(ciphertext, newKey, enrollment.userId);
34988
+ let rotated = false;
34989
+ const nextFactors = await Promise.all(factors.map(async (factor) => {
34990
+ if (factor.type !== "totp")
34991
+ return factor;
34992
+ const secret = await tryDecrypt(factor.secretCiphertext, oldKey);
34993
+ if (secret === undefined) {
34994
+ await ensureReadable(factor.secretCiphertext, newKey, enrollment.userId);
34995
+ return factor;
34996
+ }
34997
+ rotated = true;
34998
+ return {
34999
+ ...factor,
35000
+ secretCiphertext: await encryptTotpSecret(secret, newKey)
35001
+ };
35002
+ }));
35003
+ if (!rotated) {
34793
35004
  const plan2 = { kind: "already" };
34794
35005
  return plan2;
34795
35006
  }
34796
35007
  const plan = {
34797
- enrollment: {
34798
- ...enrollment,
34799
- totpSecretCiphertext: await encryptTotpSecret(secret, newKey),
34800
- updatedAt: Date.now()
34801
- },
35008
+ enrollment: withMfaFactors({ ...enrollment, updatedAt: Date.now() }, nextFactors),
34802
35009
  kind: "rotate"
34803
35010
  };
34804
35011
  return plan;
@@ -34821,7 +35028,8 @@ var rotateMfaEncryptionKey = async ({
34821
35028
  // src/mfa/inMemoryMfaStore.ts
34822
35029
  var cloneEnrollment = (value) => ({
34823
35030
  ...value,
34824
- backupCodeHashes: [...value.backupCodeHashes]
35031
+ backupCodeHashes: [...value.backupCodeHashes],
35032
+ factors: value.factors?.map((factor) => ({ ...factor }))
34825
35033
  });
34826
35034
  var createInMemoryMfaStore = () => {
34827
35035
  const enrollments = new Map;
@@ -34843,6 +35051,7 @@ var createInMemoryMfaStore = () => {
34843
35051
  },
34844
35052
  completeSmsChallenge: async ({
34845
35053
  challengeId,
35054
+ factors,
34846
35055
  lastUsedAt,
34847
35056
  smsVerified,
34848
35057
  userId
@@ -34852,11 +35061,13 @@ var createInMemoryMfaStore = () => {
34852
35061
  return false;
34853
35062
  enrollments.set(userId, cloneEnrollment({
34854
35063
  ...current,
35064
+ factors: factors ?? current.factors,
34855
35065
  lastUsedAt: lastUsedAt ?? current.lastUsedAt,
34856
35066
  smsChallengeId: undefined,
34857
35067
  smsFailedAttempts: 0,
34858
35068
  smsPendingCodeExpiresAt: undefined,
34859
35069
  smsPendingCodeHash: undefined,
35070
+ smsPendingFactorId: undefined,
34860
35071
  smsPendingPurpose: undefined,
34861
35072
  smsProviderReference: undefined,
34862
35073
  smsVerified,
@@ -34917,6 +35128,7 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
34917
35128
  backup_code_hashes: jsonb("backup_code_hashes").$type().notNull().default([]),
34918
35129
  created_at_ms: bigint("created_at_ms", { mode: "number" }).notNull(),
34919
35130
  last_used_at_ms: bigint("last_used_at_ms", { mode: "number" }),
35131
+ mfa_factors: jsonb("mfa_factors").$type(),
34920
35132
  sms_challenge_id: text("sms_challenge_id"),
34921
35133
  sms_code_sent_at_ms: bigint("sms_code_sent_at_ms", { mode: "number" }),
34922
35134
  sms_failed_attempts: smallint("sms_failed_attempts").notNull().default(0),
@@ -34924,6 +35136,7 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
34924
35136
  mode: "number"
34925
35137
  }),
34926
35138
  sms_pending_code_hash: text("sms_pending_code_hash"),
35139
+ sms_pending_factor_id: text("sms_pending_factor_id"),
34927
35140
  sms_pending_purpose: text("sms_pending_purpose").$type(),
34928
35141
  sms_phone: varchar("sms_phone", { length: PHONE_LENGTH }),
34929
35142
  sms_provider_reference: text("sms_provider_reference"),
@@ -34937,12 +35150,14 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
34937
35150
  var toEnrollment = (row) => ({
34938
35151
  backupCodeHashes: row.backup_code_hashes,
34939
35152
  createdAt: row.created_at_ms,
35153
+ factors: row.mfa_factors ?? undefined,
34940
35154
  lastUsedAt: row.last_used_at_ms ?? undefined,
34941
35155
  smsChallengeId: row.sms_challenge_id ?? undefined,
34942
35156
  smsCodeSentAt: row.sms_code_sent_at_ms ?? undefined,
34943
35157
  smsFailedAttempts: row.sms_failed_attempts,
34944
35158
  smsPendingCodeExpiresAt: row.sms_pending_code_expires_at_ms ?? undefined,
34945
35159
  smsPendingCodeHash: row.sms_pending_code_hash ?? undefined,
35160
+ smsPendingFactorId: row.sms_pending_factor_id ?? undefined,
34946
35161
  smsPendingPurpose: row.sms_pending_purpose ?? undefined,
34947
35162
  smsPhone: row.sms_phone ?? undefined,
34948
35163
  smsProviderReference: row.sms_provider_reference ?? undefined,
@@ -34959,11 +35174,13 @@ var createPostgresMfaStore = (db) => {
34959
35174
  backup_code_hashes: enrollment.backupCodeHashes,
34960
35175
  created_at_ms: enrollment.createdAt,
34961
35176
  last_used_at_ms: enrollment.lastUsedAt ?? null,
35177
+ mfa_factors: enrollment.factors ?? null,
34962
35178
  sms_challenge_id: enrollment.smsChallengeId ?? null,
34963
35179
  sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
34964
35180
  sms_failed_attempts: enrollment.smsFailedAttempts ?? 0,
34965
35181
  sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
34966
35182
  sms_pending_code_hash: enrollment.smsPendingCodeHash ?? null,
35183
+ sms_pending_factor_id: enrollment.smsPendingFactorId ?? null,
34967
35184
  sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
34968
35185
  sms_phone: enrollment.smsPhone ?? null,
34969
35186
  sms_provider_reference: enrollment.smsProviderReference ?? null,
@@ -34986,11 +35203,13 @@ var createPostgresMfaStore = (db) => {
34986
35203
  });
34987
35204
  const rows = await db.insert(mfaEnrollmentsTable).values(values).onConflictDoUpdate({
34988
35205
  set: {
35206
+ mfa_factors: enrollment.factors ?? null,
34989
35207
  sms_challenge_id: challengeId,
34990
35208
  sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
34991
35209
  sms_failed_attempts: 0,
34992
35210
  sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
34993
35211
  sms_pending_code_hash: null,
35212
+ sms_pending_factor_id: enrollment.smsPendingFactorId ?? null,
34994
35213
  sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
34995
35214
  sms_phone: enrollment.smsPhone ?? null,
34996
35215
  sms_provider_reference: null,
@@ -35004,16 +35223,19 @@ var createPostgresMfaStore = (db) => {
35004
35223
  },
35005
35224
  completeSmsChallenge: async ({
35006
35225
  challengeId,
35226
+ factors,
35007
35227
  lastUsedAt,
35008
35228
  smsVerified,
35009
35229
  userId
35010
35230
  }) => {
35011
35231
  const rows = await db.update(mfaEnrollmentsTable).set({
35012
35232
  last_used_at_ms: lastUsedAt,
35233
+ mfa_factors: factors,
35013
35234
  sms_challenge_id: null,
35014
35235
  sms_failed_attempts: 0,
35015
35236
  sms_pending_code_expires_at_ms: null,
35016
35237
  sms_pending_code_hash: null,
35238
+ sms_pending_factor_id: null,
35017
35239
  sms_pending_purpose: null,
35018
35240
  sms_provider_reference: null,
35019
35241
  sms_verified: smsVerified,
@@ -39553,6 +39775,14 @@ var mfaSmsAtomicChallengeMigration = {
39553
39775
  id: "0005_sms_atomic_challenge",
39554
39776
  sql: 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "sms_challenge_id" text;'
39555
39777
  };
39778
+ var mfaMultipleFactorsMigration = {
39779
+ id: "0006_multiple_factors",
39780
+ sql: [
39781
+ 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "mfa_factors" jsonb;',
39782
+ 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "sms_pending_factor_id" text;'
39783
+ ].join(`
39784
+ `)
39785
+ };
39556
39786
  var oidcResourceAudienceMigration = {
39557
39787
  id: "0002_resource_audience",
39558
39788
  sql: [
@@ -39634,7 +39864,8 @@ var blockMigrations = {
39634
39864
  mfaSmsColumnsMigration,
39635
39865
  mfaTotpLockoutMigration,
39636
39866
  mfaSmsDeliveryPolicyMigration,
39637
- mfaSmsAtomicChallengeMigration
39867
+ mfaSmsAtomicChallengeMigration,
39868
+ mfaMultipleFactorsMigration
39638
39869
  ]
39639
39870
  },
39640
39871
  oidc: {
@@ -40500,6 +40731,7 @@ var createAuthApplications = async (configuration) => buildAuthApplications(conf
40500
40731
  export {
40501
40732
  writeWarrant,
40502
40733
  withSpan,
40734
+ withMfaFactors,
40503
40735
  withAbsoluteNativeAuthClients,
40504
40736
  webhookDeliveriesTable,
40505
40737
  webauthnRoutes,
@@ -40708,6 +40940,7 @@ export {
40708
40940
  getStatus,
40709
40941
  getRegisteredClient,
40710
40942
  getOrRefreshFederatedTokens,
40943
+ getMfaFactors,
40711
40944
  getCredentialStatus,
40712
40945
  generateTotpSecret,
40713
40946
  generateTotp,
@@ -41026,5 +41259,5 @@ export {
41026
41259
  ABSOLUTE_NATIVE_AUTH_CLIENTS_ENV
41027
41260
  };
41028
41261
 
41029
- //# debugId=245E854B8173886A64756E2164756E21
41262
+ //# debugId=8C9E5A777004332D64756E2164756E21
41030
41263
  //# sourceMappingURL=index.js.map