@absolutejs/auth 0.75.4 → 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/server.js CHANGED
@@ -7181,7 +7181,45 @@ var createLockoutGuard = ({
7181
7181
  });
7182
7182
 
7183
7183
  // src/mfa/types.ts
7184
- var isMfaEnrolled = (enrollment) => enrollment !== undefined && (enrollment.totpVerified || enrollment.smsVerified || enrollment.backupCodeHashes.length > 0);
7184
+ var LEGACY_SMS_FACTOR_ID = "legacy-sms";
7185
+ var LEGACY_TOTP_FACTOR_ID = "legacy-totp";
7186
+ var getMfaFactors = (enrollment) => {
7187
+ if (enrollment.factors !== undefined)
7188
+ return enrollment.factors;
7189
+ const factors = [];
7190
+ if (enrollment.totpSecretCiphertext) {
7191
+ factors.push({
7192
+ id: LEGACY_TOTP_FACTOR_ID,
7193
+ label: "Authenticator app",
7194
+ secretCiphertext: enrollment.totpSecretCiphertext,
7195
+ type: "totp",
7196
+ verified: enrollment.totpVerified
7197
+ });
7198
+ }
7199
+ if (enrollment.smsPhone) {
7200
+ factors.push({
7201
+ id: LEGACY_SMS_FACTOR_ID,
7202
+ label: "Text message",
7203
+ phone: enrollment.smsPhone,
7204
+ type: "sms",
7205
+ verified: enrollment.smsVerified
7206
+ });
7207
+ }
7208
+ return factors;
7209
+ };
7210
+ var isMfaEnrolled = (enrollment) => enrollment !== undefined && (getMfaFactors(enrollment).some((factor) => factor.verified) || enrollment.backupCodeHashes.length > 0);
7211
+ var withMfaFactors = (enrollment, factors) => {
7212
+ const firstSms = factors.find((factor) => factor.type === "sms");
7213
+ const firstTotp = factors.find((factor) => factor.type === "totp");
7214
+ return {
7215
+ ...enrollment,
7216
+ factors,
7217
+ smsPhone: firstSms?.phone,
7218
+ smsVerified: firstSms?.verified ?? false,
7219
+ totpSecretCiphertext: firstTotp?.secretCiphertext,
7220
+ totpVerified: firstTotp?.verified ?? false
7221
+ };
7222
+ };
7185
7223
 
7186
7224
  // src/mfa/gate.ts
7187
7225
  var createMfaGate = ({ getUserId, mfaStore }) => async (user) => isMfaEnrolled(await mfaStore.getEnrollment(getUserId(user)));
@@ -7397,6 +7435,8 @@ var hasRecentAuthentication = (session, maxAgeMs, now = Date.now()) => session.a
7397
7435
  // src/mfa/sms.ts
7398
7436
  var DECIMAL_RADIX2 = 10;
7399
7437
  var MASK_VISIBLE_DIGITS = 4;
7438
+ var FACTOR_LABEL_MAX_LENGTH = 80;
7439
+ var DEFAULT_SMS_LABEL = "Text message";
7400
7440
  var E164_PATTERN = /^\+[1-9]\d{7,14}$/u;
7401
7441
  var generateNumericCode = (length) => {
7402
7442
  const bytes = new Uint8Array(length);
@@ -7570,10 +7610,13 @@ var mfaSmsRoutes = ({
7570
7610
  smsSetupRoute = "/auth/mfa/sms/setup",
7571
7611
  smsVerifyRoute = "/auth/mfa/sms/verify"
7572
7612
  }) => new Elysia18().use(sessionStore()).post(smsSetupRoute, {
7573
- body: t12.Object({ phone: t12.String() }),
7613
+ body: t12.Object({
7614
+ label: t12.Optional(t12.String({ maxLength: FACTOR_LABEL_MAX_LENGTH })),
7615
+ phone: t12.String()
7616
+ }),
7574
7617
  cookie: t12.Cookie({ user_session_id: userSessionIdTypebox })
7575
7618
  }, async ({
7576
- body: { phone },
7619
+ body: { label, phone },
7577
7620
  cookie: { user_session_id },
7578
7621
  status,
7579
7622
  store: { session }
@@ -7601,21 +7644,35 @@ var mfaSmsRoutes = ({
7601
7644
  if (existing?.smsCodeSentAt !== undefined && now - existing.smsCodeSentAt < smsResendCooldownMs) {
7602
7645
  return status("Too Many Requests", "SMS resend cooldown active");
7603
7646
  }
7647
+ const base = existing ?? {
7648
+ backupCodeHashes: [],
7649
+ createdAt: now,
7650
+ smsVerified: false,
7651
+ totpVerified: false,
7652
+ updatedAt: now,
7653
+ userId
7654
+ };
7655
+ const factor = {
7656
+ id: crypto.randomUUID(),
7657
+ label: label?.trim() || DEFAULT_SMS_LABEL,
7658
+ phone,
7659
+ type: "sms",
7660
+ verified: false
7661
+ };
7662
+ const factors = [
7663
+ ...getMfaFactors(base).filter((existingFactor) => existingFactor.type !== "sms" || existingFactor.verified),
7664
+ factor
7665
+ ];
7604
7666
  try {
7605
7667
  await issueAndStoreSmsCode({
7606
7668
  codeLength: smsCodeLength,
7607
7669
  enrollment: {
7608
- backupCodeHashes: existing?.backupCodeHashes ?? [],
7609
- createdAt: existing?.createdAt ?? now,
7610
- lastUsedAt: existing?.lastUsedAt,
7670
+ ...withMfaFactors(base, factors),
7611
7671
  smsFailedAttempts: 0,
7672
+ smsPendingFactorId: factor.id,
7612
7673
  smsPhone: phone,
7613
- smsVerified: false,
7614
- totpFailedAttempts: existing?.totpFailedAttempts ?? 0,
7615
- totpSecretCiphertext: existing?.totpSecretCiphertext,
7616
- totpVerified: existing?.totpVerified ?? false,
7617
7674
  updatedAt: now,
7618
- userId
7675
+ userId: base.userId
7619
7676
  },
7620
7677
  mfaStore,
7621
7678
  onSendSmsCode,
@@ -7632,12 +7689,18 @@ var mfaSmsRoutes = ({
7632
7689
  throw error;
7633
7690
  return status(mapped.status, mapped.message);
7634
7691
  }
7635
- return status("OK", { phone: maskPhone(phone) });
7692
+ return status("OK", {
7693
+ factorId: factor.id,
7694
+ phone: maskPhone(phone)
7695
+ });
7636
7696
  }).post(smsVerifyRoute, {
7637
- body: t12.Object({ code: t12.String() }),
7697
+ body: t12.Object({
7698
+ code: t12.String(),
7699
+ factorId: t12.Optional(t12.String())
7700
+ }),
7638
7701
  cookie: t12.Cookie({ user_session_id: userSessionIdTypebox })
7639
7702
  }, async ({
7640
- body: { code },
7703
+ body: { code, factorId },
7641
7704
  cookie: { user_session_id },
7642
7705
  status,
7643
7706
  store: { session }
@@ -7658,6 +7721,12 @@ var mfaSmsRoutes = ({
7658
7721
  if (!enrollment?.smsPhone) {
7659
7722
  return status("Bad Request", "No SMS enrollment in progress");
7660
7723
  }
7724
+ const factors = getMfaFactors(enrollment);
7725
+ const pendingFactorId = factorId ?? enrollment.smsPendingFactorId;
7726
+ const pendingFactor = factors.find((candidate) => candidate.type === "sms" && (candidate.id === pendingFactorId || pendingFactorId === undefined && candidate.phone === enrollment.smsPhone));
7727
+ if (!pendingFactor || pendingFactor.type !== "sms") {
7728
+ return status("Bad Request", "No SMS enrollment in progress");
7729
+ }
7661
7730
  const localCodeHash = enrollment.smsPendingCodeHash;
7662
7731
  const challengeId = enrollment.smsChallengeId;
7663
7732
  const localCodeExpiresAt = enrollment.smsPendingCodeExpiresAt;
@@ -7685,7 +7754,7 @@ var mfaSmsRoutes = ({
7685
7754
  purpose: "mfa_enrollment",
7686
7755
  reference: providerReference,
7687
7756
  subject: userId,
7688
- to: enrollment.smsPhone
7757
+ to: pendingFactor.phone
7689
7758
  });
7690
7759
  if (checked.error !== undefined) {
7691
7760
  return status(checked.error.status, checked.error.message);
@@ -7701,8 +7770,10 @@ var mfaSmsRoutes = ({
7701
7770
  });
7702
7771
  return status(providerResult?.status === "max_attempts_reached" || attempts === undefined ? "Too Many Requests" : "Bad Request", providerResult?.status === "expired" ? "SMS code expired" : "Invalid SMS code");
7703
7772
  }
7773
+ const verifiedFactors = factors.map((candidate) => candidate.id === pendingFactor.id ? { ...candidate, verified: true } : candidate);
7704
7774
  const completed = await mfaStore.completeSmsChallenge({
7705
7775
  challengeId,
7776
+ factors: verifiedFactors,
7706
7777
  smsVerified: true,
7707
7778
  userId
7708
7779
  });
@@ -7732,15 +7803,50 @@ var mfaChallenge = ({
7732
7803
  smsMaxAttempts = DEFAULT_SMS_MAX_ATTEMPTS,
7733
7804
  smsResendCooldownMs = DEFAULT_SMS_RESEND_COOLDOWN_MS,
7734
7805
  totpMaxAttempts = DEFAULT_TOTP_MAX_ATTEMPTS
7735
- }) => new Elysia19().use(sessionStore()).post(challengeRoute, {
7806
+ }) => new Elysia19().use(sessionStore()).get(challengeRoute, { cookie: t13.Cookie({ user_session_id: userSessionIdTypebox }) }, async ({
7807
+ cookie: { user_session_id },
7808
+ status,
7809
+ store: { unregisteredSession }
7810
+ }) => {
7811
+ const compatibilityLayer = await createSessionCompatibilityLayer({
7812
+ authSessionStore,
7813
+ userSessionId: user_session_id.value
7814
+ });
7815
+ const challengeUnregistered = authSessionStore ? compatibilityLayer.unregisteredSession : unregisteredSession;
7816
+ const pendingId = user_session_id.value;
7817
+ const pending = pendingId ? challengeUnregistered[pendingId] : undefined;
7818
+ if (!pending) {
7819
+ return status("Unauthorized", "No MFA challenge in progress");
7820
+ }
7821
+ const user = await getChallengeUser(pending.userIdentity ?? {});
7822
+ const enrollment = user ? await mfaStore.getEnrollment(getUserId(user)) : undefined;
7823
+ if (!enrollment) {
7824
+ return status("Unauthorized", "No MFA challenge in progress");
7825
+ }
7826
+ const response = {
7827
+ backupCodesAvailable: enrollment.backupCodeHashes.length > 0,
7828
+ factors: getMfaFactors(enrollment).filter((factor) => factor.verified).map((factor) => ({
7829
+ id: factor.id,
7830
+ label: factor.label,
7831
+ phone: factor.type === "sms" ? maskPhone(factor.phone) : null,
7832
+ type: factor.type
7833
+ }))
7834
+ };
7835
+ return status("OK", response);
7836
+ }).post(challengeRoute, {
7736
7837
  body: t13.Object({
7737
7838
  action: t13.Optional(t13.Union([t13.Literal("send"), t13.Literal("verify")])),
7738
7839
  code: t13.Optional(t13.String()),
7739
- factor: t13.Optional(t13.Literal("sms"))
7840
+ factor: t13.Optional(t13.Union([
7841
+ t13.Literal("backup_codes"),
7842
+ t13.Literal("sms"),
7843
+ t13.Literal("totp")
7844
+ ])),
7845
+ factorId: t13.Optional(t13.String())
7740
7846
  }),
7741
7847
  cookie: t13.Cookie({ user_session_id: userSessionIdTypebox })
7742
7848
  }, async ({
7743
- body: { action, code, factor },
7849
+ body: { action, code, factor, factorId },
7744
7850
  cookie: { user_session_id },
7745
7851
  status,
7746
7852
  store: { session, unregisteredSession }
@@ -7761,6 +7867,7 @@ var mfaChallenge = ({
7761
7867
  if (!user || !enrollment) {
7762
7868
  return status("Unauthorized", "No MFA challenge in progress");
7763
7869
  }
7870
+ const factors = getMfaFactors(enrollment);
7764
7871
  const promote = async () => {
7765
7872
  delete challengeUnregistered[pendingId];
7766
7873
  const userSessionId = crypto.randomUUID();
@@ -7783,10 +7890,18 @@ var mfaChallenge = ({
7783
7890
  if (enrollment.smsCodeSentAt !== undefined && Date.now() - enrollment.smsCodeSentAt < smsResendCooldownMs) {
7784
7891
  return status("Too Many Requests", "SMS resend cooldown active");
7785
7892
  }
7893
+ const smsFactor = factors.find((candidate) => candidate.type === "sms" && candidate.verified && (candidate.id === factorId || factorId === undefined));
7894
+ if (!smsFactor || smsFactor.type !== "sms") {
7895
+ return status("Bad Request", "SMS factor not found");
7896
+ }
7786
7897
  try {
7787
7898
  await issueAndStoreSmsCode({
7788
7899
  codeLength: smsCodeLength,
7789
- enrollment,
7900
+ enrollment: {
7901
+ ...withMfaFactors(enrollment, factors),
7902
+ smsPendingFactorId: smsFactor.id,
7903
+ smsPhone: smsFactor.phone
7904
+ },
7790
7905
  mfaStore,
7791
7906
  onSendSmsCode,
7792
7907
  previousEnrollment: enrollment,
@@ -7802,10 +7917,19 @@ var mfaChallenge = ({
7802
7917
  throw error;
7803
7918
  return status(mapped.status, mapped.message);
7804
7919
  }
7805
- return status("OK", { status: "sent" });
7920
+ return status("OK", {
7921
+ factorId: smsFactor.id,
7922
+ phone: maskPhone(smsFactor.phone),
7923
+ status: "sent"
7924
+ });
7806
7925
  };
7807
7926
  const runSmsChallenge = async () => {
7808
- if (!enrollment.smsVerified || !enrollment.smsPhone) {
7927
+ if (action !== "send" && factorId !== undefined && enrollment.smsPendingFactorId !== undefined && factorId !== enrollment.smsPendingFactorId) {
7928
+ return status("Bad Request", "No SMS code in progress");
7929
+ }
7930
+ const selectedFactorId = enrollment.smsPendingFactorId ?? factorId;
7931
+ const smsFactor = factors.find((candidate) => candidate.type === "sms" && candidate.verified && (candidate.id === selectedFactorId || selectedFactorId === undefined));
7932
+ if (!smsFactor || smsFactor.type !== "sms") {
7809
7933
  return status("Unauthorized", "No MFA challenge in progress");
7810
7934
  }
7811
7935
  if (action === "send")
@@ -7844,7 +7968,7 @@ var mfaChallenge = ({
7844
7968
  purpose: "mfa_challenge",
7845
7969
  reference: providerReference,
7846
7970
  subject: getUserId(user),
7847
- to: enrollment.smsPhone
7971
+ to: smsFactor.phone
7848
7972
  });
7849
7973
  if (checked.error !== undefined) {
7850
7974
  return status(checked.error.status, checked.error.message);
@@ -7866,6 +7990,7 @@ var mfaChallenge = ({
7866
7990
  }
7867
7991
  const completed = await mfaStore.completeSmsChallenge({
7868
7992
  challengeId,
7993
+ factors,
7869
7994
  lastUsedAt: Date.now(),
7870
7995
  smsVerified: true,
7871
7996
  userId: getUserId(user)
@@ -7887,11 +8012,14 @@ var mfaChallenge = ({
7887
8012
  });
7888
8013
  return status("Unauthorized", "Too many attempts");
7889
8014
  }
7890
- const totpValid = enrollment.totpVerified && enrollment.totpSecretCiphertext ? await verifyTotp({
7891
- secret: await decryptTotpSecret(enrollment.totpSecretCiphertext, encryptionKey),
8015
+ const totpFactors = factors.filter((candidate) => candidate.type === "totp" && candidate.verified && (candidate.id === factorId || factorId === undefined));
8016
+ const totpChecks = factor === "backup_codes" ? [] : await Promise.all(totpFactors.map(async (candidate) => verifyTotp({
8017
+ secret: await decryptTotpSecret(candidate.secretCiphertext, encryptionKey),
7892
8018
  token: code
7893
- }) : false;
7894
- const remainingBackupHashes = totpValid ? undefined : await consumeBackupCode(code, enrollment.backupCodeHashes);
8019
+ })));
8020
+ const totpValid = totpChecks.some(Boolean);
8021
+ const mayUseBackupCode = factor === "backup_codes" || factor === undefined && factorId === undefined;
8022
+ const remainingBackupHashes = totpValid || !mayUseBackupCode ? undefined : await consumeBackupCode(code, enrollment.backupCodeHashes);
7895
8023
  if (!totpValid && remainingBackupHashes === undefined) {
7896
8024
  await mfaStore.saveEnrollment({
7897
8025
  ...enrollment,
@@ -7923,6 +8051,12 @@ var maskPhone2 = (phone) => {
7923
8051
  const suffix = phone.slice(-visibleDigits);
7924
8052
  return `${"*".repeat(Math.max(0, phone.length - visibleDigits))}${suffix}`;
7925
8053
  };
8054
+ var publicFactors = (enrollment) => enrollment ? getMfaFactors(enrollment).filter((factor) => factor.verified).map((factor) => ({
8055
+ id: factor.id,
8056
+ label: factor.label,
8057
+ phone: factor.type === "sms" ? maskPhone2(factor.phone) : null,
8058
+ type: factor.type
8059
+ })) : [];
7926
8060
  var mfaManagementRoutes = ({
7927
8061
  authSessionStore,
7928
8062
  getUserId,
@@ -7946,6 +8080,7 @@ var mfaManagementRoutes = ({
7946
8080
  const response = {
7947
8081
  backupCodesRemaining: enrollment?.backupCodeHashes.length ?? 0,
7948
8082
  enabled: isMfaEnrolled(enrollment),
8083
+ factors: publicFactors(enrollment),
7949
8084
  smsBackup: {
7950
8085
  enabled: enrollment?.smsVerified ?? false,
7951
8086
  phone: maskPhone2(enrollment?.smsPhone)
@@ -7971,11 +8106,54 @@ var mfaManagementRoutes = ({
7971
8106
  }
7972
8107
  await mfaStore.removeEnrollment(getUserId(userSession.user));
7973
8108
  return status("OK", { status: "disabled" });
8109
+ }).delete(`${managementRoute}/factors/:factorId`, {
8110
+ cookie: t14.Cookie({ user_session_id: userSessionIdTypebox }),
8111
+ params: t14.Object({ factorId: t14.String() })
8112
+ }, async ({
8113
+ cookie: { user_session_id },
8114
+ params: { factorId },
8115
+ status,
8116
+ store: { session }
8117
+ }) => {
8118
+ const userSession = await loadSessionFromSource({
8119
+ authSessionStore,
8120
+ session,
8121
+ userSessionId: user_session_id.value
8122
+ });
8123
+ if (!userSession) {
8124
+ return status("Unauthorized", "Authentication required");
8125
+ }
8126
+ if (!hasRecentAuthentication(userSession, managementAuthMaxAgeMs)) {
8127
+ return status("Unauthorized", "Recent authentication required");
8128
+ }
8129
+ const userId = getUserId(userSession.user);
8130
+ const enrollment = await mfaStore.getEnrollment(userId);
8131
+ if (!enrollment) {
8132
+ return status("Not Found", "MFA factor not found");
8133
+ }
8134
+ const factors = getMfaFactors(enrollment);
8135
+ if (!factors.some((factor) => factor.id === factorId)) {
8136
+ return status("Not Found", "MFA factor not found");
8137
+ }
8138
+ const remaining = factors.filter((factor) => factor.id !== factorId);
8139
+ if (remaining.length === 0) {
8140
+ await mfaStore.removeEnrollment(userId);
8141
+ } else {
8142
+ const hasVerifiedFactor = remaining.some((factor) => factor.verified);
8143
+ await mfaStore.saveEnrollment(withMfaFactors({
8144
+ ...enrollment,
8145
+ backupCodeHashes: hasVerifiedFactor ? enrollment.backupCodeHashes : [],
8146
+ updatedAt: Date.now()
8147
+ }, remaining));
8148
+ }
8149
+ return status("OK", { status: "removed" });
7974
8150
  });
7975
8151
 
7976
8152
  // src/mfa/totp.ts
7977
8153
  init_crypto();
7978
8154
  import { Elysia as Elysia21, t as t15 } from "elysia";
8155
+ var FACTOR_LABEL_MAX_LENGTH2 = 80;
8156
+ var DEFAULT_TOTP_LABEL = "Authenticator app";
7979
8157
  var mfaTotpRoutes = ({
7980
8158
  authSessionStore,
7981
8159
  backupCodeCount = DEFAULT_BACKUP_CODE_COUNT,
@@ -7987,7 +8165,13 @@ var mfaTotpRoutes = ({
7987
8165
  onMfaEnrolled,
7988
8166
  totpSetupRoute = "/auth/mfa/totp/setup",
7989
8167
  totpVerifyRoute = "/auth/mfa/totp/verify"
7990
- }) => new Elysia21().use(sessionStore()).post(totpSetupRoute, { cookie: t15.Cookie({ user_session_id: userSessionIdTypebox }) }, async ({
8168
+ }) => new Elysia21().use(sessionStore()).post(totpSetupRoute, {
8169
+ body: t15.Object({
8170
+ label: t15.Optional(t15.String({ maxLength: FACTOR_LABEL_MAX_LENGTH2 }))
8171
+ }),
8172
+ cookie: t15.Cookie({ user_session_id: userSessionIdTypebox })
8173
+ }, async ({
8174
+ body: { label },
7991
8175
  cookie: { user_session_id },
7992
8176
  status,
7993
8177
  store: { session }
@@ -8007,20 +8191,25 @@ var mfaTotpRoutes = ({
8007
8191
  const secret = generateTotpSecret();
8008
8192
  const existing = await mfaStore.getEnrollment(userId);
8009
8193
  const now = Date.now();
8010
- await mfaStore.saveEnrollment({
8011
- backupCodeHashes: existing?.backupCodeHashes ?? [],
8012
- createdAt: existing?.createdAt ?? now,
8013
- smsFailedAttempts: existing?.smsFailedAttempts,
8014
- smsPendingCodeExpiresAt: existing?.smsPendingCodeExpiresAt,
8015
- smsPendingCodeHash: existing?.smsPendingCodeHash,
8016
- smsPhone: existing?.smsPhone,
8017
- smsVerified: existing?.smsVerified ?? false,
8018
- totpSecretCiphertext: await encryptTotpSecret(secret, encryptionKey),
8194
+ const base = existing ?? {
8195
+ backupCodeHashes: [],
8196
+ createdAt: now,
8197
+ smsVerified: false,
8019
8198
  totpVerified: false,
8020
8199
  updatedAt: now,
8021
8200
  userId
8022
- });
8201
+ };
8202
+ const factor = {
8203
+ id: crypto.randomUUID(),
8204
+ label: label?.trim() || DEFAULT_TOTP_LABEL,
8205
+ secretCiphertext: await encryptTotpSecret(secret, encryptionKey),
8206
+ type: "totp",
8207
+ verified: false
8208
+ };
8209
+ const factors = getMfaFactors(base).filter((existingFactor) => existingFactor.type !== "totp" || existingFactor.verified);
8210
+ await mfaStore.saveEnrollment(withMfaFactors({ ...base, updatedAt: now }, [...factors, factor]));
8023
8211
  return status("OK", {
8212
+ factorId: factor.id,
8024
8213
  secret,
8025
8214
  uri: createTotpKeyUri({
8026
8215
  accountName: userId,
@@ -8029,10 +8218,13 @@ var mfaTotpRoutes = ({
8029
8218
  })
8030
8219
  });
8031
8220
  }).post(totpVerifyRoute, {
8032
- body: t15.Object({ code: t15.String() }),
8221
+ body: t15.Object({
8222
+ code: t15.String(),
8223
+ factorId: t15.Optional(t15.String())
8224
+ }),
8033
8225
  cookie: t15.Cookie({ user_session_id: userSessionIdTypebox })
8034
8226
  }, async ({
8035
- body: { code },
8227
+ body: { code, factorId },
8036
8228
  cookie: { user_session_id },
8037
8229
  status,
8038
8230
  store: { session }
@@ -8050,21 +8242,26 @@ var mfaTotpRoutes = ({
8050
8242
  }
8051
8243
  const userId = getUserId(userSession.user);
8052
8244
  const enrollment = await mfaStore.getEnrollment(userId);
8053
- if (!enrollment?.totpSecretCiphertext) {
8245
+ if (!enrollment) {
8246
+ return status("Bad Request", "No TOTP enrollment in progress");
8247
+ }
8248
+ const factors = getMfaFactors(enrollment);
8249
+ const factor = factors.find((candidate) => candidate.type === "totp" && (candidate.id === factorId || factorId === undefined && !candidate.verified));
8250
+ if (!factor || factor.type !== "totp") {
8054
8251
  return status("Bad Request", "No TOTP enrollment in progress");
8055
8252
  }
8056
- const secret = await decryptTotpSecret(enrollment.totpSecretCiphertext, encryptionKey);
8253
+ const secret = await decryptTotpSecret(factor.secretCiphertext, encryptionKey);
8057
8254
  const valid = await verifyTotp({ secret, token: code });
8058
8255
  if (!valid) {
8059
8256
  return status("Bad Request", "Invalid TOTP code");
8060
8257
  }
8061
8258
  const { codes, hashes } = await generateBackupCodes(backupCodeCount);
8062
- await mfaStore.saveEnrollment({
8259
+ const verifiedFactors = factors.map((candidate) => candidate.id === factor.id ? { ...candidate, verified: true } : candidate);
8260
+ await mfaStore.saveEnrollment(withMfaFactors({
8063
8261
  ...enrollment,
8064
8262
  backupCodeHashes: hashes,
8065
- totpVerified: true,
8066
8263
  updatedAt: Date.now()
8067
- });
8264
+ }, verifiedFactors));
8068
8265
  await onMfaEnrolled?.({ userId });
8069
8266
  return status("OK", { backupCodes: codes });
8070
8267
  });
@@ -35778,23 +35975,33 @@ var ensureReadable = async (ciphertext, key, userId) => {
35778
35975
  }
35779
35976
  };
35780
35977
  var planEnrollment = async (enrollment, oldKey, newKey) => {
35781
- const ciphertext = enrollment.totpSecretCiphertext;
35782
- if (ciphertext === undefined || ciphertext.length === 0) {
35978
+ const factors = getMfaFactors(enrollment);
35979
+ const totpFactors = factors.filter((factor) => factor.type === "totp");
35980
+ if (totpFactors.length === 0) {
35783
35981
  const plan2 = { kind: "skip" };
35784
35982
  return plan2;
35785
35983
  }
35786
- const secret = await tryDecrypt(ciphertext, oldKey);
35787
- if (secret === undefined) {
35788
- await ensureReadable(ciphertext, newKey, enrollment.userId);
35984
+ let rotated = false;
35985
+ const nextFactors = await Promise.all(factors.map(async (factor) => {
35986
+ if (factor.type !== "totp")
35987
+ return factor;
35988
+ const secret = await tryDecrypt(factor.secretCiphertext, oldKey);
35989
+ if (secret === undefined) {
35990
+ await ensureReadable(factor.secretCiphertext, newKey, enrollment.userId);
35991
+ return factor;
35992
+ }
35993
+ rotated = true;
35994
+ return {
35995
+ ...factor,
35996
+ secretCiphertext: await encryptTotpSecret(secret, newKey)
35997
+ };
35998
+ }));
35999
+ if (!rotated) {
35789
36000
  const plan2 = { kind: "already" };
35790
36001
  return plan2;
35791
36002
  }
35792
36003
  const plan = {
35793
- enrollment: {
35794
- ...enrollment,
35795
- totpSecretCiphertext: await encryptTotpSecret(secret, newKey),
35796
- updatedAt: Date.now()
35797
- },
36004
+ enrollment: withMfaFactors({ ...enrollment, updatedAt: Date.now() }, nextFactors),
35798
36005
  kind: "rotate"
35799
36006
  };
35800
36007
  return plan;
@@ -35817,7 +36024,8 @@ var rotateMfaEncryptionKey = async ({
35817
36024
  // src/mfa/inMemoryMfaStore.ts
35818
36025
  var cloneEnrollment = (value) => ({
35819
36026
  ...value,
35820
- backupCodeHashes: [...value.backupCodeHashes]
36027
+ backupCodeHashes: [...value.backupCodeHashes],
36028
+ factors: value.factors?.map((factor) => ({ ...factor }))
35821
36029
  });
35822
36030
  var createInMemoryMfaStore = () => {
35823
36031
  const enrollments = new Map;
@@ -35839,6 +36047,7 @@ var createInMemoryMfaStore = () => {
35839
36047
  },
35840
36048
  completeSmsChallenge: async ({
35841
36049
  challengeId,
36050
+ factors,
35842
36051
  lastUsedAt,
35843
36052
  smsVerified,
35844
36053
  userId
@@ -35848,11 +36057,13 @@ var createInMemoryMfaStore = () => {
35848
36057
  return false;
35849
36058
  enrollments.set(userId, cloneEnrollment({
35850
36059
  ...current,
36060
+ factors: factors ?? current.factors,
35851
36061
  lastUsedAt: lastUsedAt ?? current.lastUsedAt,
35852
36062
  smsChallengeId: undefined,
35853
36063
  smsFailedAttempts: 0,
35854
36064
  smsPendingCodeExpiresAt: undefined,
35855
36065
  smsPendingCodeHash: undefined,
36066
+ smsPendingFactorId: undefined,
35856
36067
  smsPendingPurpose: undefined,
35857
36068
  smsProviderReference: undefined,
35858
36069
  smsVerified,
@@ -35913,6 +36124,7 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
35913
36124
  backup_code_hashes: jsonb("backup_code_hashes").$type().notNull().default([]),
35914
36125
  created_at_ms: bigint("created_at_ms", { mode: "number" }).notNull(),
35915
36126
  last_used_at_ms: bigint("last_used_at_ms", { mode: "number" }),
36127
+ mfa_factors: jsonb("mfa_factors").$type(),
35916
36128
  sms_challenge_id: text("sms_challenge_id"),
35917
36129
  sms_code_sent_at_ms: bigint("sms_code_sent_at_ms", { mode: "number" }),
35918
36130
  sms_failed_attempts: smallint("sms_failed_attempts").notNull().default(0),
@@ -35920,6 +36132,7 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
35920
36132
  mode: "number"
35921
36133
  }),
35922
36134
  sms_pending_code_hash: text("sms_pending_code_hash"),
36135
+ sms_pending_factor_id: text("sms_pending_factor_id"),
35923
36136
  sms_pending_purpose: text("sms_pending_purpose").$type(),
35924
36137
  sms_phone: varchar("sms_phone", { length: PHONE_LENGTH }),
35925
36138
  sms_provider_reference: text("sms_provider_reference"),
@@ -35933,12 +36146,14 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
35933
36146
  var toEnrollment = (row) => ({
35934
36147
  backupCodeHashes: row.backup_code_hashes,
35935
36148
  createdAt: row.created_at_ms,
36149
+ factors: row.mfa_factors ?? undefined,
35936
36150
  lastUsedAt: row.last_used_at_ms ?? undefined,
35937
36151
  smsChallengeId: row.sms_challenge_id ?? undefined,
35938
36152
  smsCodeSentAt: row.sms_code_sent_at_ms ?? undefined,
35939
36153
  smsFailedAttempts: row.sms_failed_attempts,
35940
36154
  smsPendingCodeExpiresAt: row.sms_pending_code_expires_at_ms ?? undefined,
35941
36155
  smsPendingCodeHash: row.sms_pending_code_hash ?? undefined,
36156
+ smsPendingFactorId: row.sms_pending_factor_id ?? undefined,
35942
36157
  smsPendingPurpose: row.sms_pending_purpose ?? undefined,
35943
36158
  smsPhone: row.sms_phone ?? undefined,
35944
36159
  smsProviderReference: row.sms_provider_reference ?? undefined,
@@ -35955,11 +36170,13 @@ var createPostgresMfaStore = (db) => {
35955
36170
  backup_code_hashes: enrollment.backupCodeHashes,
35956
36171
  created_at_ms: enrollment.createdAt,
35957
36172
  last_used_at_ms: enrollment.lastUsedAt ?? null,
36173
+ mfa_factors: enrollment.factors ?? null,
35958
36174
  sms_challenge_id: enrollment.smsChallengeId ?? null,
35959
36175
  sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
35960
36176
  sms_failed_attempts: enrollment.smsFailedAttempts ?? 0,
35961
36177
  sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
35962
36178
  sms_pending_code_hash: enrollment.smsPendingCodeHash ?? null,
36179
+ sms_pending_factor_id: enrollment.smsPendingFactorId ?? null,
35963
36180
  sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
35964
36181
  sms_phone: enrollment.smsPhone ?? null,
35965
36182
  sms_provider_reference: enrollment.smsProviderReference ?? null,
@@ -35982,11 +36199,13 @@ var createPostgresMfaStore = (db) => {
35982
36199
  });
35983
36200
  const rows = await db.insert(mfaEnrollmentsTable).values(values).onConflictDoUpdate({
35984
36201
  set: {
36202
+ mfa_factors: enrollment.factors ?? null,
35985
36203
  sms_challenge_id: challengeId,
35986
36204
  sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
35987
36205
  sms_failed_attempts: 0,
35988
36206
  sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
35989
36207
  sms_pending_code_hash: null,
36208
+ sms_pending_factor_id: enrollment.smsPendingFactorId ?? null,
35990
36209
  sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
35991
36210
  sms_phone: enrollment.smsPhone ?? null,
35992
36211
  sms_provider_reference: null,
@@ -36000,16 +36219,19 @@ var createPostgresMfaStore = (db) => {
36000
36219
  },
36001
36220
  completeSmsChallenge: async ({
36002
36221
  challengeId,
36222
+ factors,
36003
36223
  lastUsedAt,
36004
36224
  smsVerified,
36005
36225
  userId
36006
36226
  }) => {
36007
36227
  const rows = await db.update(mfaEnrollmentsTable).set({
36008
36228
  last_used_at_ms: lastUsedAt,
36229
+ mfa_factors: factors,
36009
36230
  sms_challenge_id: null,
36010
36231
  sms_failed_attempts: 0,
36011
36232
  sms_pending_code_expires_at_ms: null,
36012
36233
  sms_pending_code_hash: null,
36234
+ sms_pending_factor_id: null,
36013
36235
  sms_pending_purpose: null,
36014
36236
  sms_provider_reference: null,
36015
36237
  sms_verified: smsVerified,
@@ -40549,6 +40771,14 @@ var mfaSmsAtomicChallengeMigration = {
40549
40771
  id: "0005_sms_atomic_challenge",
40550
40772
  sql: 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "sms_challenge_id" text;'
40551
40773
  };
40774
+ var mfaMultipleFactorsMigration = {
40775
+ id: "0006_multiple_factors",
40776
+ sql: [
40777
+ 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "mfa_factors" jsonb;',
40778
+ 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "sms_pending_factor_id" text;'
40779
+ ].join(`
40780
+ `)
40781
+ };
40552
40782
  var oidcResourceAudienceMigration = {
40553
40783
  id: "0002_resource_audience",
40554
40784
  sql: [
@@ -40630,7 +40860,8 @@ var blockMigrations = {
40630
40860
  mfaSmsColumnsMigration,
40631
40861
  mfaTotpLockoutMigration,
40632
40862
  mfaSmsDeliveryPolicyMigration,
40633
- mfaSmsAtomicChallengeMigration
40863
+ mfaSmsAtomicChallengeMigration,
40864
+ mfaMultipleFactorsMigration
40634
40865
  ]
40635
40866
  },
40636
40867
  oidc: {
@@ -41672,5 +41903,5 @@ export {
41672
41903
  VerificationProviderError
41673
41904
  };
41674
41905
 
41675
- //# debugId=A14F7F9FF66E9B8A64756E2164756E21
41906
+ //# debugId=5FE7751779ECD0D364756E2164756E21
41676
41907
  //# sourceMappingURL=server.js.map