@absolutejs/auth 0.58.0 → 0.59.1

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
@@ -6844,6 +6844,7 @@ var generateBackupCodes = async (count) => {
6844
6844
  init_constants();
6845
6845
  var DEFAULT_BACKUP_CODE_COUNT = 10;
6846
6846
  var DEFAULT_MFA_ISSUER = "AbsoluteAuth";
6847
+ var DEFAULT_MFA_MANAGEMENT_AUTH_MAX_AGE_MS = 5 * SECONDS_IN_A_MINUTE * MILLISECONDS_IN_A_SECOND;
6847
6848
  var DEFAULT_MFA_SESSION_TTL_MS = MILLISECONDS_IN_A_DAY;
6848
6849
  var DEFAULT_SMS_CODE_LENGTH = 6;
6849
6850
  var SMS_CODE_TTL_MINUTES = 5;
@@ -6873,6 +6874,9 @@ class VerificationProviderError extends Error {
6873
6874
  }
6874
6875
  }
6875
6876
 
6877
+ // src/mfa/recentAuth.ts
6878
+ var hasRecentAuthentication = (session, maxAgeMs, now = Date.now()) => session.authenticatedAt !== undefined && now >= session.authenticatedAt && now - session.authenticatedAt <= maxAgeMs;
6879
+
6876
6880
  // src/mfa/sms.ts
6877
6881
  var DECIMAL_RADIX2 = 10;
6878
6882
  var MASK_VISIBLE_DIGITS = 4;
@@ -6888,6 +6892,46 @@ var issueSmsCode = async (codeLength, ttlMs) => {
6888
6892
  const expiresAt = Date.now() + ttlMs;
6889
6893
  return { code, expiresAt, hash };
6890
6894
  };
6895
+ var startProviderSmsChallenge = async (input) => {
6896
+ let started;
6897
+ try {
6898
+ started = await input.verificationProvider.start({
6899
+ channel: "sms",
6900
+ purpose: input.purpose,
6901
+ subject: input.userId,
6902
+ to: input.phone
6903
+ });
6904
+ } catch (error) {
6905
+ await input.mfaStore.rollbackSmsChallenge({
6906
+ challengeId: input.challengeId,
6907
+ previous: input.previousEnrollment,
6908
+ userId: input.userId
6909
+ });
6910
+ throw error;
6911
+ }
6912
+ const finalized = await input.mfaStore.finalizeSmsChallenge({
6913
+ challengeId: input.challengeId,
6914
+ expiresAt: started.expiresAt,
6915
+ providerReference: started.reference,
6916
+ userId: input.userId
6917
+ });
6918
+ if (finalized)
6919
+ return started;
6920
+ await input.verificationProvider.cancel({
6921
+ channel: "sms",
6922
+ purpose: input.purpose,
6923
+ reference: started.reference,
6924
+ subject: input.userId,
6925
+ to: input.phone
6926
+ }).catch(() => {
6927
+ return;
6928
+ });
6929
+ throw new SmsChallengeConflictError("SMS challenge claim was lost");
6930
+ };
6931
+
6932
+ class SmsChallengeConflictError extends Error {
6933
+ name = "SmsChallengeConflictError";
6934
+ }
6891
6935
  var checkWithVerificationProvider = async (provider, input) => {
6892
6936
  try {
6893
6937
  return { result: await provider.check(input) };
@@ -6904,7 +6948,9 @@ var issueAndStoreSmsCode = async ({
6904
6948
  enrollment,
6905
6949
  mfaStore,
6906
6950
  onSendSmsCode,
6951
+ previousEnrollment,
6907
6952
  purpose,
6953
+ resendCooldownMs,
6908
6954
  verificationProvider,
6909
6955
  ttlMs,
6910
6956
  userId
@@ -6912,40 +6958,62 @@ var issueAndStoreSmsCode = async ({
6912
6958
  const phone = enrollment.smsPhone;
6913
6959
  if (phone === undefined)
6914
6960
  return;
6915
- if (verificationProvider !== undefined) {
6916
- const started = await verificationProvider.start({
6917
- channel: "sms",
6918
- purpose,
6919
- subject: userId,
6920
- to: phone
6921
- });
6922
- await mfaStore.saveEnrollment({
6961
+ const challengeId = crypto.randomUUID();
6962
+ const now = Date.now();
6963
+ const claimed = await mfaStore.claimSmsChallenge({
6964
+ challengeId,
6965
+ cooldownCutoff: now - resendCooldownMs,
6966
+ enrollment: {
6923
6967
  ...enrollment,
6924
- smsCodeSentAt: Date.now(),
6968
+ smsChallengeId: challengeId,
6969
+ smsCodeSentAt: now,
6925
6970
  smsFailedAttempts: 0,
6926
- smsPendingCodeExpiresAt: started.expiresAt,
6971
+ smsPendingCodeExpiresAt: now + ttlMs,
6927
6972
  smsPendingCodeHash: undefined,
6928
6973
  smsPendingPurpose: purpose,
6929
- smsProviderReference: started.reference,
6930
- updatedAt: Date.now()
6974
+ smsProviderReference: undefined,
6975
+ updatedAt: now
6976
+ }
6977
+ });
6978
+ if (!claimed)
6979
+ throw new SmsChallengeConflictError("SMS resend cooldown active");
6980
+ if (verificationProvider !== undefined) {
6981
+ const started = await startProviderSmsChallenge({
6982
+ challengeId,
6983
+ mfaStore,
6984
+ phone,
6985
+ previousEnrollment,
6986
+ purpose,
6987
+ userId,
6988
+ verificationProvider
6931
6989
  });
6932
6990
  return started.expiresAt;
6933
6991
  }
6934
6992
  const { code, expiresAt, hash } = await issueSmsCode(codeLength, ttlMs);
6935
- await mfaStore.saveEnrollment({
6936
- ...enrollment,
6937
- smsCodeSentAt: Date.now(),
6938
- smsFailedAttempts: 0,
6939
- smsPendingCodeExpiresAt: expiresAt,
6940
- smsPendingCodeHash: hash,
6941
- smsPendingPurpose: purpose,
6942
- smsProviderReference: undefined,
6943
- updatedAt: Date.now()
6993
+ const finalized = await mfaStore.finalizeSmsChallenge({
6994
+ challengeId,
6995
+ expiresAt,
6996
+ hash,
6997
+ userId
6944
6998
  });
6945
- await onSendSmsCode?.({ code, expiresAt, phone, purpose, userId });
6999
+ if (!finalized)
7000
+ throw new SmsChallengeConflictError("SMS challenge claim was lost");
7001
+ try {
7002
+ await onSendSmsCode?.({ code, expiresAt, phone, purpose, userId });
7003
+ } catch (error) {
7004
+ await mfaStore.rollbackSmsChallenge({
7005
+ challengeId,
7006
+ previous: previousEnrollment,
7007
+ userId
7008
+ });
7009
+ throw error;
7010
+ }
6946
7011
  return expiresAt;
6947
7012
  };
6948
7013
  var mapVerificationProviderError = (error) => {
7014
+ if (error instanceof SmsChallengeConflictError) {
7015
+ return { message: error.message, status: "Too Many Requests" };
7016
+ }
6949
7017
  if (!(error instanceof VerificationProviderError))
6950
7018
  return;
6951
7019
  if (error.kind === "rate_limited") {
@@ -6974,6 +7042,7 @@ var mfaSmsRoutes = ({
6974
7042
  authSessionStore,
6975
7043
  getUserId,
6976
7044
  mfaStore,
7045
+ managementAuthMaxAgeMs = DEFAULT_MFA_MANAGEMENT_AUTH_MAX_AGE_MS,
6977
7046
  onMfaEnrolled,
6978
7047
  onSendSmsCode,
6979
7048
  verificationProvider,
@@ -7000,6 +7069,9 @@ var mfaSmsRoutes = ({
7000
7069
  if (!userSession) {
7001
7070
  return status("Unauthorized", "Authentication required");
7002
7071
  }
7072
+ if (!hasRecentAuthentication(userSession, managementAuthMaxAgeMs)) {
7073
+ return status("Unauthorized", "Recent authentication required");
7074
+ }
7003
7075
  if (!isE164Phone(phone)) {
7004
7076
  return status("Bad Request", "Phone must be in E.164 format");
7005
7077
  }
@@ -7009,48 +7081,37 @@ var mfaSmsRoutes = ({
7009
7081
  if (existing?.smsCodeSentAt !== undefined && now - existing.smsCodeSentAt < smsResendCooldownMs) {
7010
7082
  return status("Too Many Requests", "SMS resend cooldown active");
7011
7083
  }
7012
- let started;
7013
7084
  try {
7014
- started = verificationProvider ? await verificationProvider.start({
7015
- channel: "sms",
7085
+ await issueAndStoreSmsCode({
7086
+ codeLength: smsCodeLength,
7087
+ enrollment: {
7088
+ backupCodeHashes: existing?.backupCodeHashes ?? [],
7089
+ createdAt: existing?.createdAt ?? now,
7090
+ lastUsedAt: existing?.lastUsedAt,
7091
+ smsFailedAttempts: 0,
7092
+ smsPhone: phone,
7093
+ smsVerified: false,
7094
+ totpFailedAttempts: existing?.totpFailedAttempts ?? 0,
7095
+ totpSecretCiphertext: existing?.totpSecretCiphertext,
7096
+ totpVerified: existing?.totpVerified ?? false,
7097
+ updatedAt: now,
7098
+ userId
7099
+ },
7100
+ mfaStore,
7101
+ onSendSmsCode,
7102
+ previousEnrollment: existing,
7016
7103
  purpose: "mfa_enrollment",
7017
- subject: userId,
7018
- to: phone
7019
- }) : undefined;
7104
+ resendCooldownMs: smsResendCooldownMs,
7105
+ ttlMs: smsCodeTtlMs,
7106
+ userId,
7107
+ verificationProvider
7108
+ });
7020
7109
  } catch (error) {
7021
7110
  const mapped = mapVerificationProviderError(error);
7022
7111
  if (mapped === undefined)
7023
7112
  throw error;
7024
7113
  return status(mapped.status, mapped.message);
7025
7114
  }
7026
- const local = started ? undefined : await issueSmsCode(smsCodeLength, smsCodeTtlMs);
7027
- await mfaStore.saveEnrollment({
7028
- backupCodeHashes: existing?.backupCodeHashes ?? [],
7029
- createdAt: existing?.createdAt ?? now,
7030
- lastUsedAt: existing?.lastUsedAt,
7031
- smsCodeSentAt: now,
7032
- smsFailedAttempts: 0,
7033
- smsPendingCodeExpiresAt: started?.expiresAt ?? local?.expiresAt,
7034
- smsPendingCodeHash: local?.hash,
7035
- smsPendingPurpose: "mfa_enrollment",
7036
- smsPhone: phone,
7037
- smsProviderReference: started?.reference,
7038
- smsVerified: false,
7039
- totpFailedAttempts: existing?.totpFailedAttempts ?? 0,
7040
- totpSecretCiphertext: existing?.totpSecretCiphertext,
7041
- totpVerified: existing?.totpVerified ?? false,
7042
- updatedAt: now,
7043
- userId
7044
- });
7045
- if (local !== undefined && onSendSmsCode !== undefined) {
7046
- await onSendSmsCode({
7047
- code: local.code,
7048
- expiresAt: local.expiresAt,
7049
- phone,
7050
- purpose: "mfa_enrollment",
7051
- userId
7052
- });
7053
- }
7054
7115
  return status("OK", { phone: maskPhone(phone) });
7055
7116
  }, {
7056
7117
  body: t11.Object({ phone: t11.String() }),
@@ -7069,15 +7130,19 @@ var mfaSmsRoutes = ({
7069
7130
  if (!userSession) {
7070
7131
  return status("Unauthorized", "Authentication required");
7071
7132
  }
7133
+ if (!hasRecentAuthentication(userSession, managementAuthMaxAgeMs)) {
7134
+ return status("Unauthorized", "Recent authentication required");
7135
+ }
7072
7136
  const userId = getUserId(userSession.user);
7073
7137
  const enrollment = await mfaStore.getEnrollment(userId);
7074
7138
  if (!enrollment?.smsPhone) {
7075
7139
  return status("Bad Request", "No SMS enrollment in progress");
7076
7140
  }
7077
7141
  const localCodeHash = enrollment.smsPendingCodeHash;
7142
+ const challengeId = enrollment.smsChallengeId;
7078
7143
  const localCodeExpiresAt = enrollment.smsPendingCodeExpiresAt;
7079
7144
  const providerReference = enrollment.smsProviderReference;
7080
- if (enrollment.smsPendingPurpose !== "mfa_enrollment" || localCodeExpiresAt === undefined) {
7145
+ if (enrollment.smsPendingPurpose !== "mfa_enrollment" || localCodeExpiresAt === undefined || challengeId === undefined) {
7081
7146
  return status("Bad Request", "No SMS enrollment in progress");
7082
7147
  }
7083
7148
  if (verificationProvider === undefined && localCodeHash === undefined) {
@@ -7109,23 +7174,21 @@ var mfaSmsRoutes = ({
7109
7174
  }
7110
7175
  const codeValid = providerResult ? providerResult.status === "approved" : localCodeHash !== undefined && await constantTimeEqual(await hashToken(code), localCodeHash);
7111
7176
  if (!codeValid) {
7112
- await mfaStore.saveEnrollment({
7113
- ...enrollment,
7114
- smsFailedAttempts: (enrollment.smsFailedAttempts ?? 0) + 1,
7115
- updatedAt: Date.now()
7177
+ const attempts = await mfaStore.recordSmsFailure({
7178
+ challengeId,
7179
+ maxAttempts: smsMaxAttempts,
7180
+ userId
7116
7181
  });
7117
- return status(providerResult?.status === "max_attempts_reached" ? "Too Many Requests" : "Bad Request", providerResult?.status === "expired" ? "SMS code expired" : "Invalid SMS code");
7182
+ return status(providerResult?.status === "max_attempts_reached" || attempts === undefined ? "Too Many Requests" : "Bad Request", providerResult?.status === "expired" ? "SMS code expired" : "Invalid SMS code");
7118
7183
  }
7119
- await mfaStore.saveEnrollment({
7120
- ...enrollment,
7121
- smsFailedAttempts: 0,
7122
- smsPendingCodeExpiresAt: undefined,
7123
- smsPendingCodeHash: undefined,
7124
- smsPendingPurpose: undefined,
7125
- smsProviderReference: undefined,
7184
+ const completed = await mfaStore.completeSmsChallenge({
7185
+ challengeId,
7126
7186
  smsVerified: true,
7127
- updatedAt: Date.now()
7187
+ userId
7128
7188
  });
7189
+ if (!completed) {
7190
+ return status("Bad Request", "SMS challenge is no longer active");
7191
+ }
7129
7192
  await onMfaEnrolled?.({ userId });
7130
7193
  return status("OK", { status: "enrolled" });
7131
7194
  }, {
@@ -7202,7 +7265,9 @@ var mfaChallenge = ({
7202
7265
  enrollment,
7203
7266
  mfaStore,
7204
7267
  onSendSmsCode,
7268
+ previousEnrollment: enrollment,
7205
7269
  purpose: "mfa_challenge",
7270
+ resendCooldownMs: smsResendCooldownMs,
7206
7271
  ttlMs: smsCodeTtlMs,
7207
7272
  userId: getUserId(user),
7208
7273
  verificationProvider
@@ -7225,9 +7290,10 @@ var mfaChallenge = ({
7225
7290
  return status("Bad Request", "SMS code required");
7226
7291
  }
7227
7292
  const localCodeHash = enrollment.smsPendingCodeHash;
7293
+ const challengeId = enrollment.smsChallengeId;
7228
7294
  const localCodeExpiresAt = enrollment.smsPendingCodeExpiresAt;
7229
7295
  const providerReference = enrollment.smsProviderReference;
7230
- if (enrollment.smsPendingPurpose !== "mfa_challenge" || localCodeExpiresAt === undefined) {
7296
+ if (enrollment.smsPendingPurpose !== "mfa_challenge" || localCodeExpiresAt === undefined || challengeId === undefined) {
7231
7297
  return status("Bad Request", "No SMS code in progress");
7232
7298
  }
7233
7299
  if (verificationProvider === undefined && localCodeHash === undefined) {
@@ -7263,27 +7329,26 @@ var mfaChallenge = ({
7263
7329
  }
7264
7330
  const smsValid = providerResult ? providerResult.status === "approved" : localCodeHash !== undefined && await constantTimeEqual(await hashToken(code), localCodeHash);
7265
7331
  if (!smsValid) {
7266
- await mfaStore.saveEnrollment({
7267
- ...enrollment,
7268
- smsFailedAttempts: (enrollment.smsFailedAttempts ?? 0) + 1,
7269
- updatedAt: Date.now()
7332
+ const attempts = await mfaStore.recordSmsFailure({
7333
+ challengeId,
7334
+ maxAttempts: smsMaxAttempts,
7335
+ userId: getUserId(user)
7270
7336
  });
7271
7337
  await onMfaChallengeError?.({
7272
7338
  error: new Error("invalid_mfa_code"),
7273
7339
  userId: getUserId(user)
7274
7340
  });
7275
- return status(providerResult?.status === "max_attempts_reached" ? "Too Many Requests" : "Unauthorized", providerResult?.status === "expired" ? "SMS code expired" : "Invalid MFA code");
7341
+ return status(providerResult?.status === "max_attempts_reached" || attempts === undefined ? "Too Many Requests" : "Unauthorized", providerResult?.status === "expired" ? "SMS code expired" : "Invalid MFA code");
7276
7342
  }
7277
- await mfaStore.saveEnrollment({
7278
- ...enrollment,
7343
+ const completed = await mfaStore.completeSmsChallenge({
7344
+ challengeId,
7279
7345
  lastUsedAt: Date.now(),
7280
- smsFailedAttempts: 0,
7281
- smsPendingCodeExpiresAt: undefined,
7282
- smsPendingCodeHash: undefined,
7283
- smsPendingPurpose: undefined,
7284
- smsProviderReference: undefined,
7285
- updatedAt: Date.now()
7346
+ smsVerified: true,
7347
+ userId: getUserId(user)
7286
7348
  });
7349
+ if (!completed) {
7350
+ return status("Unauthorized", "SMS challenge is no longer active");
7351
+ }
7287
7352
  return promote();
7288
7353
  };
7289
7354
  if (factor === "sms")
@@ -7345,6 +7410,7 @@ var mfaManagementRoutes = ({
7345
7410
  authSessionStore,
7346
7411
  getUserId,
7347
7412
  managementRoute = "/auth/mfa",
7413
+ managementAuthMaxAgeMs = DEFAULT_MFA_MANAGEMENT_AUTH_MAX_AGE_MS,
7348
7414
  mfaStore
7349
7415
  }) => new Elysia19().use(sessionStore()).get(managementRoute, async ({
7350
7416
  cookie: { user_session_id },
@@ -7383,6 +7449,9 @@ var mfaManagementRoutes = ({
7383
7449
  if (!userSession) {
7384
7450
  return status("Unauthorized", "Authentication required");
7385
7451
  }
7452
+ if (!hasRecentAuthentication(userSession, managementAuthMaxAgeMs)) {
7453
+ return status("Unauthorized", "Recent authentication required");
7454
+ }
7386
7455
  await mfaStore.removeEnrollment(getUserId(userSession.user));
7387
7456
  return status("OK", { status: "disabled" });
7388
7457
  }, { cookie: t13.Cookie({ user_session_id: userSessionIdTypebox }) });
@@ -7396,6 +7465,7 @@ var mfaTotpRoutes = ({
7396
7465
  encryptionKey,
7397
7466
  getUserId,
7398
7467
  issuer = DEFAULT_MFA_ISSUER,
7468
+ managementAuthMaxAgeMs = DEFAULT_MFA_MANAGEMENT_AUTH_MAX_AGE_MS,
7399
7469
  mfaStore,
7400
7470
  onMfaEnrolled,
7401
7471
  totpSetupRoute = "/auth/mfa/totp/setup",
@@ -7413,6 +7483,9 @@ var mfaTotpRoutes = ({
7413
7483
  if (!userSession) {
7414
7484
  return status("Unauthorized", "Authentication required");
7415
7485
  }
7486
+ if (!hasRecentAuthentication(userSession, managementAuthMaxAgeMs)) {
7487
+ return status("Unauthorized", "Recent authentication required");
7488
+ }
7416
7489
  const userId = getUserId(userSession.user);
7417
7490
  const secret = generateTotpSecret();
7418
7491
  const existing = await mfaStore.getEnrollment(userId);
@@ -7452,6 +7525,9 @@ var mfaTotpRoutes = ({
7452
7525
  if (!userSession) {
7453
7526
  return status("Unauthorized", "Authentication required");
7454
7527
  }
7528
+ if (!hasRecentAuthentication(userSession, managementAuthMaxAgeMs)) {
7529
+ return status("Unauthorized", "Recent authentication required");
7530
+ }
7455
7531
  const userId = getUserId(userSession.user);
7456
7532
  const enrollment = await mfaStore.getEnrollment(userId);
7457
7533
  if (!enrollment?.totpSecretCiphertext) {
@@ -32990,14 +33066,85 @@ var cloneEnrollment = (value) => ({
32990
33066
  var createInMemoryMfaStore = () => {
32991
33067
  const enrollments = new Map;
32992
33068
  return {
33069
+ claimSmsChallenge: async ({
33070
+ challengeId,
33071
+ cooldownCutoff,
33072
+ enrollment
33073
+ }) => {
33074
+ const current = enrollments.get(enrollment.userId);
33075
+ if (current?.smsCodeSentAt !== undefined && current.smsCodeSentAt > cooldownCutoff) {
33076
+ return false;
33077
+ }
33078
+ enrollments.set(enrollment.userId, cloneEnrollment({
33079
+ ...enrollment,
33080
+ smsChallengeId: challengeId
33081
+ }));
33082
+ return true;
33083
+ },
33084
+ completeSmsChallenge: async ({
33085
+ challengeId,
33086
+ lastUsedAt,
33087
+ smsVerified,
33088
+ userId
33089
+ }) => {
33090
+ const current = enrollments.get(userId);
33091
+ if (current?.smsChallengeId !== challengeId)
33092
+ return false;
33093
+ enrollments.set(userId, cloneEnrollment({
33094
+ ...current,
33095
+ lastUsedAt: lastUsedAt ?? current.lastUsedAt,
33096
+ smsChallengeId: undefined,
33097
+ smsFailedAttempts: 0,
33098
+ smsPendingCodeExpiresAt: undefined,
33099
+ smsPendingCodeHash: undefined,
33100
+ smsPendingPurpose: undefined,
33101
+ smsProviderReference: undefined,
33102
+ smsVerified,
33103
+ updatedAt: Date.now()
33104
+ }));
33105
+ return true;
33106
+ },
33107
+ finalizeSmsChallenge: async (input) => {
33108
+ const current = enrollments.get(input.userId);
33109
+ if (current?.smsChallengeId !== input.challengeId)
33110
+ return false;
33111
+ enrollments.set(input.userId, cloneEnrollment({
33112
+ ...current,
33113
+ smsPendingCodeExpiresAt: input.expiresAt,
33114
+ smsPendingCodeHash: input.hash,
33115
+ smsProviderReference: input.providerReference,
33116
+ updatedAt: Date.now()
33117
+ }));
33118
+ return true;
33119
+ },
32993
33120
  getEnrollment: async (userId) => {
32994
33121
  const enrollment = enrollments.get(userId);
32995
33122
  return enrollment ? cloneEnrollment(enrollment) : undefined;
32996
33123
  },
32997
33124
  listEnrollments: async () => Array.from(enrollments.values()).map(cloneEnrollment),
33125
+ recordSmsFailure: async ({ challengeId, maxAttempts, userId }) => {
33126
+ const current = enrollments.get(userId);
33127
+ if (current?.smsChallengeId !== challengeId || (current.smsFailedAttempts ?? 0) >= maxAttempts)
33128
+ return;
33129
+ const attempts = (current.smsFailedAttempts ?? 0) + 1;
33130
+ enrollments.set(userId, cloneEnrollment({
33131
+ ...current,
33132
+ smsFailedAttempts: attempts,
33133
+ updatedAt: Date.now()
33134
+ }));
33135
+ return attempts;
33136
+ },
32998
33137
  removeEnrollment: async (userId) => {
32999
33138
  enrollments.delete(userId);
33000
33139
  },
33140
+ rollbackSmsChallenge: async ({ challengeId, previous, userId }) => {
33141
+ if (enrollments.get(userId)?.smsChallengeId !== challengeId)
33142
+ return;
33143
+ if (previous)
33144
+ enrollments.set(userId, cloneEnrollment(previous));
33145
+ else
33146
+ enrollments.delete(userId);
33147
+ },
33001
33148
  saveEnrollment: async (enrollment) => {
33002
33149
  enrollments.set(enrollment.userId, cloneEnrollment(enrollment));
33003
33150
  }
@@ -33010,6 +33157,7 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
33010
33157
  backup_code_hashes: jsonb("backup_code_hashes").$type().notNull().default([]),
33011
33158
  created_at_ms: bigint("created_at_ms", { mode: "number" }).notNull(),
33012
33159
  last_used_at_ms: bigint("last_used_at_ms", { mode: "number" }),
33160
+ sms_challenge_id: text("sms_challenge_id"),
33013
33161
  sms_code_sent_at_ms: bigint("sms_code_sent_at_ms", { mode: "number" }),
33014
33162
  sms_failed_attempts: smallint("sms_failed_attempts").notNull().default(0),
33015
33163
  sms_pending_code_expires_at_ms: bigint("sms_pending_code_expires_at_ms", {
@@ -33030,6 +33178,7 @@ var toEnrollment = (row) => ({
33030
33178
  backupCodeHashes: row.backup_code_hashes,
33031
33179
  createdAt: row.created_at_ms,
33032
33180
  lastUsedAt: row.last_used_at_ms ?? undefined,
33181
+ smsChallengeId: row.sms_challenge_id ?? undefined,
33033
33182
  smsCodeSentAt: row.sms_code_sent_at_ms ?? undefined,
33034
33183
  smsFailedAttempts: row.sms_failed_attempts,
33035
33184
  smsPendingCodeExpiresAt: row.sms_pending_code_expires_at_ms ?? undefined,
@@ -33045,43 +33194,118 @@ var toEnrollment = (row) => ({
33045
33194
  userId: row.user_id
33046
33195
  });
33047
33196
  var createNeonMfaStore = (databaseUrl) => createPostgresMfaStore(createNeonDatabase(databaseUrl));
33048
- var createPostgresMfaStore = (db) => ({
33049
- getEnrollment: async (userId) => {
33050
- const [row] = await db.select().from(mfaEnrollmentsTable).where(eq(mfaEnrollmentsTable.user_id, userId)).limit(1);
33051
- return row ? toEnrollment(row) : undefined;
33052
- },
33053
- listEnrollments: async () => {
33054
- const rows = await db.select().from(mfaEnrollmentsTable);
33055
- return rows.map(toEnrollment);
33056
- },
33057
- removeEnrollment: async (userId) => {
33058
- await db.delete(mfaEnrollmentsTable).where(eq(mfaEnrollmentsTable.user_id, userId));
33059
- },
33060
- saveEnrollment: async (enrollment) => {
33061
- const values = {
33062
- backup_code_hashes: enrollment.backupCodeHashes,
33063
- created_at_ms: enrollment.createdAt,
33064
- last_used_at_ms: enrollment.lastUsedAt ?? null,
33065
- sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
33066
- sms_failed_attempts: enrollment.smsFailedAttempts ?? 0,
33067
- sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
33068
- sms_pending_code_hash: enrollment.smsPendingCodeHash ?? null,
33069
- sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
33070
- sms_phone: enrollment.smsPhone ?? null,
33071
- sms_provider_reference: enrollment.smsProviderReference ?? null,
33072
- sms_verified: enrollment.smsVerified,
33073
- totp_failed_attempts: enrollment.totpFailedAttempts ?? 0,
33074
- totp_secret_ciphertext: enrollment.totpSecretCiphertext ?? null,
33075
- totp_verified: enrollment.totpVerified,
33076
- updated_at_ms: enrollment.updatedAt,
33077
- user_id: enrollment.userId
33078
- };
33079
- await db.insert(mfaEnrollmentsTable).values(values).onConflictDoUpdate({
33080
- set: values,
33081
- target: mfaEnrollmentsTable.user_id
33082
- });
33083
- }
33084
- });
33197
+ var createPostgresMfaStore = (db) => {
33198
+ const toValues = (enrollment) => ({
33199
+ backup_code_hashes: enrollment.backupCodeHashes,
33200
+ created_at_ms: enrollment.createdAt,
33201
+ last_used_at_ms: enrollment.lastUsedAt ?? null,
33202
+ sms_challenge_id: enrollment.smsChallengeId ?? null,
33203
+ sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
33204
+ sms_failed_attempts: enrollment.smsFailedAttempts ?? 0,
33205
+ sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
33206
+ sms_pending_code_hash: enrollment.smsPendingCodeHash ?? null,
33207
+ sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
33208
+ sms_phone: enrollment.smsPhone ?? null,
33209
+ sms_provider_reference: enrollment.smsProviderReference ?? null,
33210
+ sms_verified: enrollment.smsVerified,
33211
+ totp_failed_attempts: enrollment.totpFailedAttempts ?? 0,
33212
+ totp_secret_ciphertext: enrollment.totpSecretCiphertext ?? null,
33213
+ totp_verified: enrollment.totpVerified,
33214
+ updated_at_ms: enrollment.updatedAt,
33215
+ user_id: enrollment.userId
33216
+ });
33217
+ return {
33218
+ claimSmsChallenge: async ({
33219
+ challengeId,
33220
+ cooldownCutoff,
33221
+ enrollment
33222
+ }) => {
33223
+ const values = toValues({
33224
+ ...enrollment,
33225
+ smsChallengeId: challengeId
33226
+ });
33227
+ const rows = await db.insert(mfaEnrollmentsTable).values(values).onConflictDoUpdate({
33228
+ set: {
33229
+ sms_challenge_id: challengeId,
33230
+ sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
33231
+ sms_failed_attempts: 0,
33232
+ sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
33233
+ sms_pending_code_hash: null,
33234
+ sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
33235
+ sms_phone: enrollment.smsPhone ?? null,
33236
+ sms_provider_reference: null,
33237
+ sms_verified: enrollment.smsVerified,
33238
+ updated_at_ms: enrollment.updatedAt
33239
+ },
33240
+ setWhere: or(isNull(mfaEnrollmentsTable.sms_code_sent_at_ms), lte(mfaEnrollmentsTable.sms_code_sent_at_ms, cooldownCutoff)),
33241
+ target: mfaEnrollmentsTable.user_id
33242
+ }).returning({ userId: mfaEnrollmentsTable.user_id });
33243
+ return rows.length === 1;
33244
+ },
33245
+ completeSmsChallenge: async ({
33246
+ challengeId,
33247
+ lastUsedAt,
33248
+ smsVerified,
33249
+ userId
33250
+ }) => {
33251
+ const rows = await db.update(mfaEnrollmentsTable).set({
33252
+ last_used_at_ms: lastUsedAt,
33253
+ sms_challenge_id: null,
33254
+ sms_failed_attempts: 0,
33255
+ sms_pending_code_expires_at_ms: null,
33256
+ sms_pending_code_hash: null,
33257
+ sms_pending_purpose: null,
33258
+ sms_provider_reference: null,
33259
+ sms_verified: smsVerified,
33260
+ updated_at_ms: Date.now()
33261
+ }).where(and(eq(mfaEnrollmentsTable.user_id, userId), eq(mfaEnrollmentsTable.sms_challenge_id, challengeId))).returning({ userId: mfaEnrollmentsTable.user_id });
33262
+ return rows.length === 1;
33263
+ },
33264
+ finalizeSmsChallenge: async (input) => {
33265
+ const rows = await db.update(mfaEnrollmentsTable).set({
33266
+ sms_pending_code_expires_at_ms: input.expiresAt,
33267
+ sms_pending_code_hash: input.hash ?? null,
33268
+ sms_provider_reference: input.providerReference ?? null,
33269
+ updated_at_ms: Date.now()
33270
+ }).where(and(eq(mfaEnrollmentsTable.user_id, input.userId), eq(mfaEnrollmentsTable.sms_challenge_id, input.challengeId))).returning({ userId: mfaEnrollmentsTable.user_id });
33271
+ return rows.length === 1;
33272
+ },
33273
+ getEnrollment: async (userId) => {
33274
+ const [row] = await db.select().from(mfaEnrollmentsTable).where(eq(mfaEnrollmentsTable.user_id, userId)).limit(1);
33275
+ return row ? toEnrollment(row) : undefined;
33276
+ },
33277
+ listEnrollments: async () => {
33278
+ const rows = await db.select().from(mfaEnrollmentsTable);
33279
+ return rows.map(toEnrollment);
33280
+ },
33281
+ recordSmsFailure: async ({ challengeId, maxAttempts, userId }) => {
33282
+ const rows = await db.update(mfaEnrollmentsTable).set({
33283
+ sms_failed_attempts: sql`${mfaEnrollmentsTable.sms_failed_attempts} + 1`,
33284
+ updated_at_ms: Date.now()
33285
+ }).where(and(eq(mfaEnrollmentsTable.user_id, userId), eq(mfaEnrollmentsTable.sms_challenge_id, challengeId), lte(mfaEnrollmentsTable.sms_failed_attempts, maxAttempts - 1))).returning({
33286
+ attempts: mfaEnrollmentsTable.sms_failed_attempts
33287
+ });
33288
+ return rows[0]?.attempts;
33289
+ },
33290
+ removeEnrollment: async (userId) => {
33291
+ await db.delete(mfaEnrollmentsTable).where(eq(mfaEnrollmentsTable.user_id, userId));
33292
+ },
33293
+ rollbackSmsChallenge: async ({ challengeId, previous, userId }) => {
33294
+ if (previous) {
33295
+ await db.update(mfaEnrollmentsTable).set(toValues(previous)).where(and(eq(mfaEnrollmentsTable.user_id, userId), eq(mfaEnrollmentsTable.sms_challenge_id, challengeId)));
33296
+ return;
33297
+ }
33298
+ await db.delete(mfaEnrollmentsTable).where(and(eq(mfaEnrollmentsTable.user_id, userId), eq(mfaEnrollmentsTable.sms_challenge_id, challengeId)));
33299
+ },
33300
+ saveEnrollment: async (enrollment) => {
33301
+ const values = toValues(enrollment);
33302
+ await db.insert(mfaEnrollmentsTable).values(values).onConflictDoUpdate({
33303
+ set: values,
33304
+ target: mfaEnrollmentsTable.user_id
33305
+ });
33306
+ }
33307
+ };
33308
+ };
33085
33309
  // src/audit/types.ts
33086
33310
  var AUDIT_EVENT_TYPES = [
33087
33311
  "account_deleted",
@@ -37426,6 +37650,10 @@ var mfaSmsDeliveryPolicyMigration = {
37426
37650
  ].join(`
37427
37651
  `)
37428
37652
  };
37653
+ var mfaSmsAtomicChallengeMigration = {
37654
+ id: "0005_sms_atomic_challenge",
37655
+ sql: 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "sms_challenge_id" text;'
37656
+ };
37429
37657
  var oidcResourceAudienceMigration = {
37430
37658
  id: "0002_resource_audience",
37431
37659
  sql: [
@@ -37481,7 +37709,8 @@ var blockMigrations = {
37481
37709
  ...initMigration("mfa", [mfaEnrollmentsTable]).migrations,
37482
37710
  mfaSmsColumnsMigration,
37483
37711
  mfaTotpLockoutMigration,
37484
- mfaSmsDeliveryPolicyMigration
37712
+ mfaSmsDeliveryPolicyMigration,
37713
+ mfaSmsAtomicChallengeMigration
37485
37714
  ]
37486
37715
  },
37487
37716
  oidc: {
@@ -38479,5 +38708,5 @@ export {
38479
38708
  VerificationProviderError
38480
38709
  };
38481
38710
 
38482
- //# debugId=AAFF3B14FB3E90D564756E2164756E21
38711
+ //# debugId=82A372899BEA4F4564756E2164756E21
38483
38712
  //# sourceMappingURL=server.js.map