@absolutejs/auth 0.75.7 → 0.76.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/index.js CHANGED
@@ -3372,8 +3372,8 @@ var failureResponse = (config, failure, requiredScopes) => new Response(JSON.str
3372
3372
  var agentAuthContextPlugin = (config) => new Elysia2({
3373
3373
  name: "@absolutejs/auth/agent-context",
3374
3374
  seed: pluginDependencySeed(config)
3375
- }).derive(({ request }) => {
3376
- const protectAgent = async (requiredScopes, handleAuth, handleAuthFail) => {
3375
+ }).derive(({ request }) => ({
3376
+ protectAgent: async (requiredScopes, handleAuth, handleAuthFail) => {
3377
3377
  if (config === undefined) {
3378
3378
  const failure = {
3379
3379
  code: "Unauthorized",
@@ -3399,9 +3399,8 @@ var agentAuthContextPlugin = (config) => new Elysia2({
3399
3399
  return await handleAuthFail?.(failure) ?? failureResponse(config, failure, requiredScopes);
3400
3400
  }
3401
3401
  return handleAuth(principal);
3402
- };
3403
- return { protectAgent };
3404
- }).as("global");
3402
+ }
3403
+ })).as("global");
3405
3404
 
3406
3405
  // src/agents/oauthGuide.ts
3407
3406
  var DEFAULT_GUIDE_ROUTE = "/auth.md";
@@ -4612,17 +4611,17 @@ var protectPermissionPlugin = ({
4612
4611
  }).use(sessionStore()).guard({
4613
4612
  cookie: t3.Cookie({ user_session_id: userSessionIdTypebox }),
4614
4613
  schema: "merge"
4615
- }).derive(({ store: { session }, cookie: { user_session_id }, status }) => {
4616
- const protectPermission = (check, handleAuth, handleAuthFail) => getStatusFromSource({
4614
+ }).derive(({ store: { session }, cookie: { user_session_id }, status }) => ({
4615
+ protectPermission: (check, handleAuth, handleAuthFail) => getStatusFromSource({
4617
4616
  authSessionStore,
4618
4617
  session,
4619
4618
  user_session_id
4620
4619
  }).then(async ({ user, error }) => {
4621
4620
  if (error) {
4622
- return await handleAuthFail?.(error) ?? status(error.code, error.message);
4621
+ return handleAuthFail?.(error) ?? status(error.code, error.message);
4623
4622
  }
4624
4623
  if (!user) {
4625
- return await handleAuthFail?.({
4624
+ return handleAuthFail?.({
4626
4625
  code: "Unauthorized",
4627
4626
  message: "User is not authenticated"
4628
4627
  }) ?? status("Unauthorized", "User is not authenticated");
@@ -4639,15 +4638,14 @@ var protectPermissionPlugin = ({
4639
4638
  organizationId: check.organizationId,
4640
4639
  type: "authorization_denied"
4641
4640
  });
4642
- return await handleAuthFail?.({
4641
+ return handleAuthFail?.({
4643
4642
  code: "Forbidden",
4644
4643
  message: "Insufficient permissions"
4645
4644
  }) ?? status("Forbidden", "Insufficient permissions");
4646
4645
  }
4647
4646
  return handleAuth(user);
4648
- });
4649
- return { protectPermission };
4650
- }).as("global");
4647
+ })
4648
+ })).as("global");
4651
4649
 
4652
4650
  // src/routes/protectRoute.ts
4653
4651
  import { Elysia as Elysia6, t as t4 } from "elysia";
@@ -4711,7 +4709,8 @@ var protectRoutePlugin = ({
4711
4709
  }).derive(async ({
4712
4710
  store: { session },
4713
4711
  cookie: { user_session_id },
4714
- headers
4712
+ headers,
4713
+ status
4715
4714
  }) => {
4716
4715
  const sessionStatus = await getStatusFromSource({
4717
4716
  authSessionStore,
@@ -4730,30 +4729,25 @@ var protectRoutePlugin = ({
4730
4729
  authorization: headers.authorization,
4731
4730
  config: accessTokens
4732
4731
  });
4733
- return { absoluteAuthStatus: sessionStatus, authPrincipal };
4734
- }).derive(({ absoluteAuthStatus, authPrincipal, status }) => {
4735
- const protectRoute = async (handleAuth, handleAuthFail) => {
4736
- if (!absoluteAuthStatus)
4737
- return await handleAuthFail?.({
4738
- code: "Unauthorized",
4739
- message: "User is not authenticated"
4740
- }) ?? status("Unauthorized", "User is not authenticated");
4741
- const user = authPrincipal?.user;
4742
- const { error } = absoluteAuthStatus;
4743
- if (error) {
4744
- if (user)
4745
- return handleAuth(user);
4746
- return await handleAuthFail?.(error) ?? status(error.code, error.message);
4747
- }
4748
- if (!user) {
4749
- return await handleAuthFail?.({
4750
- code: "Unauthorized",
4751
- message: "User is not authenticated"
4752
- }) ?? status("Unauthorized", "User is not authenticated");
4732
+ return {
4733
+ authPrincipal,
4734
+ protectRoute: (handleAuth, handleAuthFail) => {
4735
+ const user = authPrincipal?.user;
4736
+ const { error } = sessionStatus;
4737
+ if (error) {
4738
+ if (user)
4739
+ return handleAuth(user);
4740
+ return handleAuthFail?.(error) ?? status(error.code, error.message);
4741
+ }
4742
+ if (!user) {
4743
+ return handleAuthFail?.({
4744
+ code: "Unauthorized",
4745
+ message: "User is not authenticated"
4746
+ }) ?? status("Unauthorized", "User is not authenticated");
4747
+ }
4748
+ return handleAuth(user);
4753
4749
  }
4754
- return handleAuth(user);
4755
4750
  };
4756
- return { protectRoute };
4757
4751
  }).as("global");
4758
4752
 
4759
4753
  // src/routes/stepUp.ts
@@ -4766,24 +4760,23 @@ var stepUpPlugin = ({
4766
4760
  }).use(sessionStore()).guard({
4767
4761
  cookie: t5.Cookie({ user_session_id: userSessionIdTypebox }),
4768
4762
  schema: "merge"
4769
- }).derive(({ store: { session }, cookie: { user_session_id }, status }) => {
4770
- const requireRecentAuth = (maxAgeMs, handleAuth, handleAuthFail) => loadSessionFromSource({
4763
+ }).derive(({ store: { session }, cookie: { user_session_id }, status }) => ({
4764
+ requireRecentAuth: (maxAgeMs, handleAuth, handleAuthFail) => loadSessionFromSource({
4771
4765
  authSessionStore,
4772
4766
  session,
4773
4767
  userSessionId: user_session_id.value
4774
- }).then(async (userSession) => {
4768
+ }).then((userSession) => {
4775
4769
  const authenticatedAt = userSession?.authenticatedAt;
4776
4770
  const isRecent = authenticatedAt !== undefined && Date.now() - authenticatedAt <= maxAgeMs;
4777
4771
  if (!userSession || !isRecent) {
4778
- return await handleAuthFail?.({
4772
+ return handleAuthFail?.({
4779
4773
  code: "Unauthorized",
4780
4774
  message: "Recent authentication required"
4781
4775
  }) ?? status("Unauthorized", "Recent authentication required");
4782
4776
  }
4783
4777
  return handleAuth(userSession.user);
4784
- });
4785
- return { requireRecentAuth };
4786
- }).as("global");
4778
+ })
4779
+ })).as("global");
4787
4780
 
4788
4781
  // src/oidc/socketTickets.ts
4789
4782
  init_crypto();
@@ -6194,7 +6187,45 @@ var createLockoutGuard = ({
6194
6187
  });
6195
6188
 
6196
6189
  // src/mfa/types.ts
6197
- var isMfaEnrolled = (enrollment) => enrollment !== undefined && (enrollment.totpVerified || enrollment.smsVerified || enrollment.backupCodeHashes.length > 0);
6190
+ var LEGACY_SMS_FACTOR_ID = "legacy-sms";
6191
+ var LEGACY_TOTP_FACTOR_ID = "legacy-totp";
6192
+ var getMfaFactors = (enrollment) => {
6193
+ if (enrollment.factors !== undefined)
6194
+ return enrollment.factors;
6195
+ const factors = [];
6196
+ if (enrollment.totpSecretCiphertext) {
6197
+ factors.push({
6198
+ id: LEGACY_TOTP_FACTOR_ID,
6199
+ label: "Authenticator app",
6200
+ secretCiphertext: enrollment.totpSecretCiphertext,
6201
+ type: "totp",
6202
+ verified: enrollment.totpVerified
6203
+ });
6204
+ }
6205
+ if (enrollment.smsPhone) {
6206
+ factors.push({
6207
+ id: LEGACY_SMS_FACTOR_ID,
6208
+ label: "Text message",
6209
+ phone: enrollment.smsPhone,
6210
+ type: "sms",
6211
+ verified: enrollment.smsVerified
6212
+ });
6213
+ }
6214
+ return factors;
6215
+ };
6216
+ var isMfaEnrolled = (enrollment) => enrollment !== undefined && (getMfaFactors(enrollment).some((factor) => factor.verified) || enrollment.backupCodeHashes.length > 0);
6217
+ var withMfaFactors = (enrollment, factors) => {
6218
+ const firstSms = factors.find((factor) => factor.type === "sms");
6219
+ const firstTotp = factors.find((factor) => factor.type === "totp");
6220
+ return {
6221
+ ...enrollment,
6222
+ factors,
6223
+ smsPhone: firstSms?.phone,
6224
+ smsVerified: firstSms?.verified ?? false,
6225
+ totpSecretCiphertext: firstTotp?.secretCiphertext,
6226
+ totpVerified: firstTotp?.verified ?? false
6227
+ };
6228
+ };
6198
6229
 
6199
6230
  // src/mfa/gate.ts
6200
6231
  var createMfaGate = ({ getUserId, mfaStore }) => async (user) => isMfaEnrolled(await mfaStore.getEnrollment(getUserId(user)));
@@ -6410,6 +6441,8 @@ var hasRecentAuthentication = (session, maxAgeMs, now = Date.now()) => session.a
6410
6441
  // src/mfa/sms.ts
6411
6442
  var DECIMAL_RADIX2 = 10;
6412
6443
  var MASK_VISIBLE_DIGITS = 4;
6444
+ var FACTOR_LABEL_MAX_LENGTH = 80;
6445
+ var DEFAULT_SMS_LABEL = "Text message";
6413
6446
  var E164_PATTERN = /^\+[1-9]\d{7,14}$/u;
6414
6447
  var generateNumericCode = (length) => {
6415
6448
  const bytes = new Uint8Array(length);
@@ -6583,10 +6616,13 @@ var mfaSmsRoutes = ({
6583
6616
  smsSetupRoute = "/auth/mfa/sms/setup",
6584
6617
  smsVerifyRoute = "/auth/mfa/sms/verify"
6585
6618
  }) => new Elysia18().use(sessionStore()).post(smsSetupRoute, {
6586
- body: t12.Object({ phone: t12.String() }),
6619
+ body: t12.Object({
6620
+ label: t12.Optional(t12.String({ maxLength: FACTOR_LABEL_MAX_LENGTH })),
6621
+ phone: t12.String()
6622
+ }),
6587
6623
  cookie: t12.Cookie({ user_session_id: userSessionIdTypebox })
6588
6624
  }, async ({
6589
- body: { phone },
6625
+ body: { label, phone },
6590
6626
  cookie: { user_session_id },
6591
6627
  status,
6592
6628
  store: { session }
@@ -6614,21 +6650,35 @@ var mfaSmsRoutes = ({
6614
6650
  if (existing?.smsCodeSentAt !== undefined && now - existing.smsCodeSentAt < smsResendCooldownMs) {
6615
6651
  return status("Too Many Requests", "SMS resend cooldown active");
6616
6652
  }
6653
+ const base = existing ?? {
6654
+ backupCodeHashes: [],
6655
+ createdAt: now,
6656
+ smsVerified: false,
6657
+ totpVerified: false,
6658
+ updatedAt: now,
6659
+ userId
6660
+ };
6661
+ const factor = {
6662
+ id: crypto.randomUUID(),
6663
+ label: label?.trim() || DEFAULT_SMS_LABEL,
6664
+ phone,
6665
+ type: "sms",
6666
+ verified: false
6667
+ };
6668
+ const factors = [
6669
+ ...getMfaFactors(base).filter((existingFactor) => existingFactor.type !== "sms" || existingFactor.verified),
6670
+ factor
6671
+ ];
6617
6672
  try {
6618
6673
  await issueAndStoreSmsCode({
6619
6674
  codeLength: smsCodeLength,
6620
6675
  enrollment: {
6621
- backupCodeHashes: existing?.backupCodeHashes ?? [],
6622
- createdAt: existing?.createdAt ?? now,
6623
- lastUsedAt: existing?.lastUsedAt,
6676
+ ...withMfaFactors(base, factors),
6624
6677
  smsFailedAttempts: 0,
6678
+ smsPendingFactorId: factor.id,
6625
6679
  smsPhone: phone,
6626
- smsVerified: false,
6627
- totpFailedAttempts: existing?.totpFailedAttempts ?? 0,
6628
- totpSecretCiphertext: existing?.totpSecretCiphertext,
6629
- totpVerified: existing?.totpVerified ?? false,
6630
6680
  updatedAt: now,
6631
- userId
6681
+ userId: base.userId
6632
6682
  },
6633
6683
  mfaStore,
6634
6684
  onSendSmsCode,
@@ -6645,12 +6695,18 @@ var mfaSmsRoutes = ({
6645
6695
  throw error;
6646
6696
  return status(mapped.status, mapped.message);
6647
6697
  }
6648
- return status("OK", { phone: maskPhone(phone) });
6698
+ return status("OK", {
6699
+ factorId: factor.id,
6700
+ phone: maskPhone(phone)
6701
+ });
6649
6702
  }).post(smsVerifyRoute, {
6650
- body: t12.Object({ code: t12.String() }),
6703
+ body: t12.Object({
6704
+ code: t12.String(),
6705
+ factorId: t12.Optional(t12.String())
6706
+ }),
6651
6707
  cookie: t12.Cookie({ user_session_id: userSessionIdTypebox })
6652
6708
  }, async ({
6653
- body: { code },
6709
+ body: { code, factorId },
6654
6710
  cookie: { user_session_id },
6655
6711
  status,
6656
6712
  store: { session }
@@ -6671,6 +6727,12 @@ var mfaSmsRoutes = ({
6671
6727
  if (!enrollment?.smsPhone) {
6672
6728
  return status("Bad Request", "No SMS enrollment in progress");
6673
6729
  }
6730
+ const factors = getMfaFactors(enrollment);
6731
+ const pendingFactorId = factorId ?? enrollment.smsPendingFactorId;
6732
+ const pendingFactor = factors.find((candidate) => candidate.type === "sms" && (candidate.id === pendingFactorId || pendingFactorId === undefined && candidate.phone === enrollment.smsPhone));
6733
+ if (!pendingFactor || pendingFactor.type !== "sms") {
6734
+ return status("Bad Request", "No SMS enrollment in progress");
6735
+ }
6674
6736
  const localCodeHash = enrollment.smsPendingCodeHash;
6675
6737
  const challengeId = enrollment.smsChallengeId;
6676
6738
  const localCodeExpiresAt = enrollment.smsPendingCodeExpiresAt;
@@ -6698,7 +6760,7 @@ var mfaSmsRoutes = ({
6698
6760
  purpose: "mfa_enrollment",
6699
6761
  reference: providerReference,
6700
6762
  subject: userId,
6701
- to: enrollment.smsPhone
6763
+ to: pendingFactor.phone
6702
6764
  });
6703
6765
  if (checked.error !== undefined) {
6704
6766
  return status(checked.error.status, checked.error.message);
@@ -6714,8 +6776,10 @@ var mfaSmsRoutes = ({
6714
6776
  });
6715
6777
  return status(providerResult?.status === "max_attempts_reached" || attempts === undefined ? "Too Many Requests" : "Bad Request", providerResult?.status === "expired" ? "SMS code expired" : "Invalid SMS code");
6716
6778
  }
6779
+ const verifiedFactors = factors.map((candidate) => candidate.id === pendingFactor.id ? { ...candidate, verified: true } : candidate);
6717
6780
  const completed = await mfaStore.completeSmsChallenge({
6718
6781
  challengeId,
6782
+ factors: verifiedFactors,
6719
6783
  smsVerified: true,
6720
6784
  userId
6721
6785
  });
@@ -6745,15 +6809,50 @@ var mfaChallenge = ({
6745
6809
  smsMaxAttempts = DEFAULT_SMS_MAX_ATTEMPTS,
6746
6810
  smsResendCooldownMs = DEFAULT_SMS_RESEND_COOLDOWN_MS,
6747
6811
  totpMaxAttempts = DEFAULT_TOTP_MAX_ATTEMPTS
6748
- }) => new Elysia19().use(sessionStore()).post(challengeRoute, {
6812
+ }) => new Elysia19().use(sessionStore()).get(challengeRoute, { cookie: t13.Cookie({ user_session_id: userSessionIdTypebox }) }, async ({
6813
+ cookie: { user_session_id },
6814
+ status,
6815
+ store: { unregisteredSession }
6816
+ }) => {
6817
+ const compatibilityLayer = await createSessionCompatibilityLayer({
6818
+ authSessionStore,
6819
+ userSessionId: user_session_id.value
6820
+ });
6821
+ const challengeUnregistered = authSessionStore ? compatibilityLayer.unregisteredSession : unregisteredSession;
6822
+ const pendingId = user_session_id.value;
6823
+ const pending = pendingId ? challengeUnregistered[pendingId] : undefined;
6824
+ if (!pending) {
6825
+ return status("Unauthorized", "No MFA challenge in progress");
6826
+ }
6827
+ const user = await getChallengeUser(pending.userIdentity ?? {});
6828
+ const enrollment = user ? await mfaStore.getEnrollment(getUserId(user)) : undefined;
6829
+ if (!enrollment) {
6830
+ return status("Unauthorized", "No MFA challenge in progress");
6831
+ }
6832
+ const response = {
6833
+ backupCodesAvailable: enrollment.backupCodeHashes.length > 0,
6834
+ factors: getMfaFactors(enrollment).filter((factor) => factor.verified).map((factor) => ({
6835
+ id: factor.id,
6836
+ label: factor.label,
6837
+ phone: factor.type === "sms" ? maskPhone(factor.phone) : null,
6838
+ type: factor.type
6839
+ }))
6840
+ };
6841
+ return status("OK", response);
6842
+ }).post(challengeRoute, {
6749
6843
  body: t13.Object({
6750
6844
  action: t13.Optional(t13.Union([t13.Literal("send"), t13.Literal("verify")])),
6751
6845
  code: t13.Optional(t13.String()),
6752
- factor: t13.Optional(t13.Literal("sms"))
6846
+ factor: t13.Optional(t13.Union([
6847
+ t13.Literal("backup_codes"),
6848
+ t13.Literal("sms"),
6849
+ t13.Literal("totp")
6850
+ ])),
6851
+ factorId: t13.Optional(t13.String())
6753
6852
  }),
6754
6853
  cookie: t13.Cookie({ user_session_id: userSessionIdTypebox })
6755
6854
  }, async ({
6756
- body: { action, code, factor },
6855
+ body: { action, code, factor, factorId },
6757
6856
  cookie: { user_session_id },
6758
6857
  status,
6759
6858
  store: { session, unregisteredSession }
@@ -6774,6 +6873,7 @@ var mfaChallenge = ({
6774
6873
  if (!user || !enrollment) {
6775
6874
  return status("Unauthorized", "No MFA challenge in progress");
6776
6875
  }
6876
+ const factors = getMfaFactors(enrollment);
6777
6877
  const promote = async () => {
6778
6878
  delete challengeUnregistered[pendingId];
6779
6879
  const userSessionId = crypto.randomUUID();
@@ -6796,10 +6896,18 @@ var mfaChallenge = ({
6796
6896
  if (enrollment.smsCodeSentAt !== undefined && Date.now() - enrollment.smsCodeSentAt < smsResendCooldownMs) {
6797
6897
  return status("Too Many Requests", "SMS resend cooldown active");
6798
6898
  }
6899
+ const smsFactor = factors.find((candidate) => candidate.type === "sms" && candidate.verified && (candidate.id === factorId || factorId === undefined));
6900
+ if (!smsFactor || smsFactor.type !== "sms") {
6901
+ return status("Bad Request", "SMS factor not found");
6902
+ }
6799
6903
  try {
6800
6904
  await issueAndStoreSmsCode({
6801
6905
  codeLength: smsCodeLength,
6802
- enrollment,
6906
+ enrollment: {
6907
+ ...withMfaFactors(enrollment, factors),
6908
+ smsPendingFactorId: smsFactor.id,
6909
+ smsPhone: smsFactor.phone
6910
+ },
6803
6911
  mfaStore,
6804
6912
  onSendSmsCode,
6805
6913
  previousEnrollment: enrollment,
@@ -6815,10 +6923,19 @@ var mfaChallenge = ({
6815
6923
  throw error;
6816
6924
  return status(mapped.status, mapped.message);
6817
6925
  }
6818
- return status("OK", { status: "sent" });
6926
+ return status("OK", {
6927
+ factorId: smsFactor.id,
6928
+ phone: maskPhone(smsFactor.phone),
6929
+ status: "sent"
6930
+ });
6819
6931
  };
6820
6932
  const runSmsChallenge = async () => {
6821
- if (!enrollment.smsVerified || !enrollment.smsPhone) {
6933
+ if (action !== "send" && factorId !== undefined && enrollment.smsPendingFactorId !== undefined && factorId !== enrollment.smsPendingFactorId) {
6934
+ return status("Bad Request", "No SMS code in progress");
6935
+ }
6936
+ const selectedFactorId = enrollment.smsPendingFactorId ?? factorId;
6937
+ const smsFactor = factors.find((candidate) => candidate.type === "sms" && candidate.verified && (candidate.id === selectedFactorId || selectedFactorId === undefined));
6938
+ if (!smsFactor || smsFactor.type !== "sms") {
6822
6939
  return status("Unauthorized", "No MFA challenge in progress");
6823
6940
  }
6824
6941
  if (action === "send")
@@ -6857,7 +6974,7 @@ var mfaChallenge = ({
6857
6974
  purpose: "mfa_challenge",
6858
6975
  reference: providerReference,
6859
6976
  subject: getUserId(user),
6860
- to: enrollment.smsPhone
6977
+ to: smsFactor.phone
6861
6978
  });
6862
6979
  if (checked.error !== undefined) {
6863
6980
  return status(checked.error.status, checked.error.message);
@@ -6879,6 +6996,7 @@ var mfaChallenge = ({
6879
6996
  }
6880
6997
  const completed = await mfaStore.completeSmsChallenge({
6881
6998
  challengeId,
6999
+ factors,
6882
7000
  lastUsedAt: Date.now(),
6883
7001
  smsVerified: true,
6884
7002
  userId: getUserId(user)
@@ -6900,11 +7018,14 @@ var mfaChallenge = ({
6900
7018
  });
6901
7019
  return status("Unauthorized", "Too many attempts");
6902
7020
  }
6903
- const totpValid = enrollment.totpVerified && enrollment.totpSecretCiphertext ? await verifyTotp({
6904
- secret: await decryptTotpSecret(enrollment.totpSecretCiphertext, encryptionKey),
7021
+ const totpFactors = factors.filter((candidate) => candidate.type === "totp" && candidate.verified && (candidate.id === factorId || factorId === undefined));
7022
+ const totpChecks = factor === "backup_codes" ? [] : await Promise.all(totpFactors.map(async (candidate) => verifyTotp({
7023
+ secret: await decryptTotpSecret(candidate.secretCiphertext, encryptionKey),
6905
7024
  token: code
6906
- }) : false;
6907
- const remainingBackupHashes = totpValid ? undefined : await consumeBackupCode(code, enrollment.backupCodeHashes);
7025
+ })));
7026
+ const totpValid = totpChecks.some(Boolean);
7027
+ const mayUseBackupCode = factor === "backup_codes" || factor === undefined && factorId === undefined;
7028
+ const remainingBackupHashes = totpValid || !mayUseBackupCode ? undefined : await consumeBackupCode(code, enrollment.backupCodeHashes);
6908
7029
  if (!totpValid && remainingBackupHashes === undefined) {
6909
7030
  await mfaStore.saveEnrollment({
6910
7031
  ...enrollment,
@@ -6936,6 +7057,12 @@ var maskPhone2 = (phone) => {
6936
7057
  const suffix = phone.slice(-visibleDigits);
6937
7058
  return `${"*".repeat(Math.max(0, phone.length - visibleDigits))}${suffix}`;
6938
7059
  };
7060
+ var publicFactors = (enrollment) => enrollment ? getMfaFactors(enrollment).filter((factor) => factor.verified).map((factor) => ({
7061
+ id: factor.id,
7062
+ label: factor.label,
7063
+ phone: factor.type === "sms" ? maskPhone2(factor.phone) : null,
7064
+ type: factor.type
7065
+ })) : [];
6939
7066
  var mfaManagementRoutes = ({
6940
7067
  authSessionStore,
6941
7068
  getUserId,
@@ -6959,6 +7086,7 @@ var mfaManagementRoutes = ({
6959
7086
  const response = {
6960
7087
  backupCodesRemaining: enrollment?.backupCodeHashes.length ?? 0,
6961
7088
  enabled: isMfaEnrolled(enrollment),
7089
+ factors: publicFactors(enrollment),
6962
7090
  smsBackup: {
6963
7091
  enabled: enrollment?.smsVerified ?? false,
6964
7092
  phone: maskPhone2(enrollment?.smsPhone)
@@ -6984,11 +7112,54 @@ var mfaManagementRoutes = ({
6984
7112
  }
6985
7113
  await mfaStore.removeEnrollment(getUserId(userSession.user));
6986
7114
  return status("OK", { status: "disabled" });
7115
+ }).delete(`${managementRoute}/factors/:factorId`, {
7116
+ cookie: t14.Cookie({ user_session_id: userSessionIdTypebox }),
7117
+ params: t14.Object({ factorId: t14.String() })
7118
+ }, async ({
7119
+ cookie: { user_session_id },
7120
+ params: { factorId },
7121
+ status,
7122
+ store: { session }
7123
+ }) => {
7124
+ const userSession = await loadSessionFromSource({
7125
+ authSessionStore,
7126
+ session,
7127
+ userSessionId: user_session_id.value
7128
+ });
7129
+ if (!userSession) {
7130
+ return status("Unauthorized", "Authentication required");
7131
+ }
7132
+ if (!hasRecentAuthentication(userSession, managementAuthMaxAgeMs)) {
7133
+ return status("Unauthorized", "Recent authentication required");
7134
+ }
7135
+ const userId = getUserId(userSession.user);
7136
+ const enrollment = await mfaStore.getEnrollment(userId);
7137
+ if (!enrollment) {
7138
+ return status("Not Found", "MFA factor not found");
7139
+ }
7140
+ const factors = getMfaFactors(enrollment);
7141
+ if (!factors.some((factor) => factor.id === factorId)) {
7142
+ return status("Not Found", "MFA factor not found");
7143
+ }
7144
+ const remaining = factors.filter((factor) => factor.id !== factorId);
7145
+ if (remaining.length === 0) {
7146
+ await mfaStore.removeEnrollment(userId);
7147
+ } else {
7148
+ const hasVerifiedFactor = remaining.some((factor) => factor.verified);
7149
+ await mfaStore.saveEnrollment(withMfaFactors({
7150
+ ...enrollment,
7151
+ backupCodeHashes: hasVerifiedFactor ? enrollment.backupCodeHashes : [],
7152
+ updatedAt: Date.now()
7153
+ }, remaining));
7154
+ }
7155
+ return status("OK", { status: "removed" });
6987
7156
  });
6988
7157
 
6989
7158
  // src/mfa/totp.ts
6990
7159
  init_crypto();
6991
7160
  import { Elysia as Elysia21, t as t15 } from "elysia";
7161
+ var FACTOR_LABEL_MAX_LENGTH2 = 80;
7162
+ var DEFAULT_TOTP_LABEL = "Authenticator app";
6992
7163
  var mfaTotpRoutes = ({
6993
7164
  authSessionStore,
6994
7165
  backupCodeCount = DEFAULT_BACKUP_CODE_COUNT,
@@ -7000,7 +7171,13 @@ var mfaTotpRoutes = ({
7000
7171
  onMfaEnrolled,
7001
7172
  totpSetupRoute = "/auth/mfa/totp/setup",
7002
7173
  totpVerifyRoute = "/auth/mfa/totp/verify"
7003
- }) => new Elysia21().use(sessionStore()).post(totpSetupRoute, { cookie: t15.Cookie({ user_session_id: userSessionIdTypebox }) }, async ({
7174
+ }) => new Elysia21().use(sessionStore()).post(totpSetupRoute, {
7175
+ body: t15.Object({
7176
+ label: t15.Optional(t15.String({ maxLength: FACTOR_LABEL_MAX_LENGTH2 }))
7177
+ }),
7178
+ cookie: t15.Cookie({ user_session_id: userSessionIdTypebox })
7179
+ }, async ({
7180
+ body: { label },
7004
7181
  cookie: { user_session_id },
7005
7182
  status,
7006
7183
  store: { session }
@@ -7020,20 +7197,25 @@ var mfaTotpRoutes = ({
7020
7197
  const secret = generateTotpSecret();
7021
7198
  const existing = await mfaStore.getEnrollment(userId);
7022
7199
  const now = Date.now();
7023
- await mfaStore.saveEnrollment({
7024
- backupCodeHashes: existing?.backupCodeHashes ?? [],
7025
- createdAt: existing?.createdAt ?? now,
7026
- smsFailedAttempts: existing?.smsFailedAttempts,
7027
- smsPendingCodeExpiresAt: existing?.smsPendingCodeExpiresAt,
7028
- smsPendingCodeHash: existing?.smsPendingCodeHash,
7029
- smsPhone: existing?.smsPhone,
7030
- smsVerified: existing?.smsVerified ?? false,
7031
- totpSecretCiphertext: await encryptTotpSecret(secret, encryptionKey),
7200
+ const base = existing ?? {
7201
+ backupCodeHashes: [],
7202
+ createdAt: now,
7203
+ smsVerified: false,
7032
7204
  totpVerified: false,
7033
7205
  updatedAt: now,
7034
7206
  userId
7035
- });
7207
+ };
7208
+ const factor = {
7209
+ id: crypto.randomUUID(),
7210
+ label: label?.trim() || DEFAULT_TOTP_LABEL,
7211
+ secretCiphertext: await encryptTotpSecret(secret, encryptionKey),
7212
+ type: "totp",
7213
+ verified: false
7214
+ };
7215
+ const factors = getMfaFactors(base).filter((existingFactor) => existingFactor.type !== "totp" || existingFactor.verified);
7216
+ await mfaStore.saveEnrollment(withMfaFactors({ ...base, updatedAt: now }, [...factors, factor]));
7036
7217
  return status("OK", {
7218
+ factorId: factor.id,
7037
7219
  secret,
7038
7220
  uri: createTotpKeyUri({
7039
7221
  accountName: userId,
@@ -7042,10 +7224,13 @@ var mfaTotpRoutes = ({
7042
7224
  })
7043
7225
  });
7044
7226
  }).post(totpVerifyRoute, {
7045
- body: t15.Object({ code: t15.String() }),
7227
+ body: t15.Object({
7228
+ code: t15.String(),
7229
+ factorId: t15.Optional(t15.String())
7230
+ }),
7046
7231
  cookie: t15.Cookie({ user_session_id: userSessionIdTypebox })
7047
7232
  }, async ({
7048
- body: { code },
7233
+ body: { code, factorId },
7049
7234
  cookie: { user_session_id },
7050
7235
  status,
7051
7236
  store: { session }
@@ -7063,21 +7248,26 @@ var mfaTotpRoutes = ({
7063
7248
  }
7064
7249
  const userId = getUserId(userSession.user);
7065
7250
  const enrollment = await mfaStore.getEnrollment(userId);
7066
- if (!enrollment?.totpSecretCiphertext) {
7251
+ if (!enrollment) {
7252
+ return status("Bad Request", "No TOTP enrollment in progress");
7253
+ }
7254
+ const factors = getMfaFactors(enrollment);
7255
+ const factor = factors.find((candidate) => candidate.type === "totp" && (candidate.id === factorId || factorId === undefined && !candidate.verified));
7256
+ if (!factor || factor.type !== "totp") {
7067
7257
  return status("Bad Request", "No TOTP enrollment in progress");
7068
7258
  }
7069
- const secret = await decryptTotpSecret(enrollment.totpSecretCiphertext, encryptionKey);
7259
+ const secret = await decryptTotpSecret(factor.secretCiphertext, encryptionKey);
7070
7260
  const valid = await verifyTotp({ secret, token: code });
7071
7261
  if (!valid) {
7072
7262
  return status("Bad Request", "Invalid TOTP code");
7073
7263
  }
7074
7264
  const { codes, hashes } = await generateBackupCodes(backupCodeCount);
7075
- await mfaStore.saveEnrollment({
7265
+ const verifiedFactors = factors.map((candidate) => candidate.id === factor.id ? { ...candidate, verified: true } : candidate);
7266
+ await mfaStore.saveEnrollment(withMfaFactors({
7076
7267
  ...enrollment,
7077
7268
  backupCodeHashes: hashes,
7078
- totpVerified: true,
7079
7269
  updatedAt: Date.now()
7080
- });
7270
+ }, verifiedFactors));
7081
7271
  await onMfaEnrolled?.({ userId });
7082
7272
  return status("OK", { backupCodes: codes });
7083
7273
  });
@@ -34952,23 +35142,33 @@ var ensureReadable = async (ciphertext, key, userId) => {
34952
35142
  }
34953
35143
  };
34954
35144
  var planEnrollment = async (enrollment, oldKey, newKey) => {
34955
- const ciphertext = enrollment.totpSecretCiphertext;
34956
- if (ciphertext === undefined || ciphertext.length === 0) {
35145
+ const factors = getMfaFactors(enrollment);
35146
+ const totpFactors = factors.filter((factor) => factor.type === "totp");
35147
+ if (totpFactors.length === 0) {
34957
35148
  const plan2 = { kind: "skip" };
34958
35149
  return plan2;
34959
35150
  }
34960
- const secret = await tryDecrypt(ciphertext, oldKey);
34961
- if (secret === undefined) {
34962
- await ensureReadable(ciphertext, newKey, enrollment.userId);
35151
+ let rotated = false;
35152
+ const nextFactors = await Promise.all(factors.map(async (factor) => {
35153
+ if (factor.type !== "totp")
35154
+ return factor;
35155
+ const secret = await tryDecrypt(factor.secretCiphertext, oldKey);
35156
+ if (secret === undefined) {
35157
+ await ensureReadable(factor.secretCiphertext, newKey, enrollment.userId);
35158
+ return factor;
35159
+ }
35160
+ rotated = true;
35161
+ return {
35162
+ ...factor,
35163
+ secretCiphertext: await encryptTotpSecret(secret, newKey)
35164
+ };
35165
+ }));
35166
+ if (!rotated) {
34963
35167
  const plan2 = { kind: "already" };
34964
35168
  return plan2;
34965
35169
  }
34966
35170
  const plan = {
34967
- enrollment: {
34968
- ...enrollment,
34969
- totpSecretCiphertext: await encryptTotpSecret(secret, newKey),
34970
- updatedAt: Date.now()
34971
- },
35171
+ enrollment: withMfaFactors({ ...enrollment, updatedAt: Date.now() }, nextFactors),
34972
35172
  kind: "rotate"
34973
35173
  };
34974
35174
  return plan;
@@ -34991,7 +35191,8 @@ var rotateMfaEncryptionKey = async ({
34991
35191
  // src/mfa/inMemoryMfaStore.ts
34992
35192
  var cloneEnrollment = (value) => ({
34993
35193
  ...value,
34994
- backupCodeHashes: [...value.backupCodeHashes]
35194
+ backupCodeHashes: [...value.backupCodeHashes],
35195
+ factors: value.factors?.map((factor) => ({ ...factor }))
34995
35196
  });
34996
35197
  var createInMemoryMfaStore = () => {
34997
35198
  const enrollments = new Map;
@@ -35013,6 +35214,7 @@ var createInMemoryMfaStore = () => {
35013
35214
  },
35014
35215
  completeSmsChallenge: async ({
35015
35216
  challengeId,
35217
+ factors,
35016
35218
  lastUsedAt,
35017
35219
  smsVerified,
35018
35220
  userId
@@ -35022,11 +35224,13 @@ var createInMemoryMfaStore = () => {
35022
35224
  return false;
35023
35225
  enrollments.set(userId, cloneEnrollment({
35024
35226
  ...current,
35227
+ factors: factors ?? current.factors,
35025
35228
  lastUsedAt: lastUsedAt ?? current.lastUsedAt,
35026
35229
  smsChallengeId: undefined,
35027
35230
  smsFailedAttempts: 0,
35028
35231
  smsPendingCodeExpiresAt: undefined,
35029
35232
  smsPendingCodeHash: undefined,
35233
+ smsPendingFactorId: undefined,
35030
35234
  smsPendingPurpose: undefined,
35031
35235
  smsProviderReference: undefined,
35032
35236
  smsVerified,
@@ -35087,6 +35291,7 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
35087
35291
  backup_code_hashes: jsonb("backup_code_hashes").$type().notNull().default([]),
35088
35292
  created_at_ms: bigint("created_at_ms", { mode: "number" }).notNull(),
35089
35293
  last_used_at_ms: bigint("last_used_at_ms", { mode: "number" }),
35294
+ mfa_factors: jsonb("mfa_factors").$type(),
35090
35295
  sms_challenge_id: text("sms_challenge_id"),
35091
35296
  sms_code_sent_at_ms: bigint("sms_code_sent_at_ms", { mode: "number" }),
35092
35297
  sms_failed_attempts: smallint("sms_failed_attempts").notNull().default(0),
@@ -35094,6 +35299,7 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
35094
35299
  mode: "number"
35095
35300
  }),
35096
35301
  sms_pending_code_hash: text("sms_pending_code_hash"),
35302
+ sms_pending_factor_id: text("sms_pending_factor_id"),
35097
35303
  sms_pending_purpose: text("sms_pending_purpose").$type(),
35098
35304
  sms_phone: varchar("sms_phone", { length: PHONE_LENGTH }),
35099
35305
  sms_provider_reference: text("sms_provider_reference"),
@@ -35107,12 +35313,14 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
35107
35313
  var toEnrollment = (row) => ({
35108
35314
  backupCodeHashes: row.backup_code_hashes,
35109
35315
  createdAt: row.created_at_ms,
35316
+ factors: row.mfa_factors ?? undefined,
35110
35317
  lastUsedAt: row.last_used_at_ms ?? undefined,
35111
35318
  smsChallengeId: row.sms_challenge_id ?? undefined,
35112
35319
  smsCodeSentAt: row.sms_code_sent_at_ms ?? undefined,
35113
35320
  smsFailedAttempts: row.sms_failed_attempts,
35114
35321
  smsPendingCodeExpiresAt: row.sms_pending_code_expires_at_ms ?? undefined,
35115
35322
  smsPendingCodeHash: row.sms_pending_code_hash ?? undefined,
35323
+ smsPendingFactorId: row.sms_pending_factor_id ?? undefined,
35116
35324
  smsPendingPurpose: row.sms_pending_purpose ?? undefined,
35117
35325
  smsPhone: row.sms_phone ?? undefined,
35118
35326
  smsProviderReference: row.sms_provider_reference ?? undefined,
@@ -35129,11 +35337,13 @@ var createPostgresMfaStore = (db) => {
35129
35337
  backup_code_hashes: enrollment.backupCodeHashes,
35130
35338
  created_at_ms: enrollment.createdAt,
35131
35339
  last_used_at_ms: enrollment.lastUsedAt ?? null,
35340
+ mfa_factors: enrollment.factors ?? null,
35132
35341
  sms_challenge_id: enrollment.smsChallengeId ?? null,
35133
35342
  sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
35134
35343
  sms_failed_attempts: enrollment.smsFailedAttempts ?? 0,
35135
35344
  sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
35136
35345
  sms_pending_code_hash: enrollment.smsPendingCodeHash ?? null,
35346
+ sms_pending_factor_id: enrollment.smsPendingFactorId ?? null,
35137
35347
  sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
35138
35348
  sms_phone: enrollment.smsPhone ?? null,
35139
35349
  sms_provider_reference: enrollment.smsProviderReference ?? null,
@@ -35156,11 +35366,13 @@ var createPostgresMfaStore = (db) => {
35156
35366
  });
35157
35367
  const rows = await db.insert(mfaEnrollmentsTable).values(values).onConflictDoUpdate({
35158
35368
  set: {
35369
+ mfa_factors: enrollment.factors ?? null,
35159
35370
  sms_challenge_id: challengeId,
35160
35371
  sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
35161
35372
  sms_failed_attempts: 0,
35162
35373
  sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
35163
35374
  sms_pending_code_hash: null,
35375
+ sms_pending_factor_id: enrollment.smsPendingFactorId ?? null,
35164
35376
  sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
35165
35377
  sms_phone: enrollment.smsPhone ?? null,
35166
35378
  sms_provider_reference: null,
@@ -35174,16 +35386,19 @@ var createPostgresMfaStore = (db) => {
35174
35386
  },
35175
35387
  completeSmsChallenge: async ({
35176
35388
  challengeId,
35389
+ factors,
35177
35390
  lastUsedAt,
35178
35391
  smsVerified,
35179
35392
  userId
35180
35393
  }) => {
35181
35394
  const rows = await db.update(mfaEnrollmentsTable).set({
35182
35395
  last_used_at_ms: lastUsedAt,
35396
+ mfa_factors: factors,
35183
35397
  sms_challenge_id: null,
35184
35398
  sms_failed_attempts: 0,
35185
35399
  sms_pending_code_expires_at_ms: null,
35186
35400
  sms_pending_code_hash: null,
35401
+ sms_pending_factor_id: null,
35187
35402
  sms_pending_purpose: null,
35188
35403
  sms_provider_reference: null,
35189
35404
  sms_verified: smsVerified,
@@ -39577,12 +39792,7 @@ var renderChunk = (chunk) => {
39577
39792
  return "";
39578
39793
  };
39579
39794
  var formatSqlTemplate = (value) => value.queryChunks.map(renderChunk).join("");
39580
- var columnSqlType = (column) => {
39581
- const dimensions = Reflect.get(column, "dimensions");
39582
- const elementType = column.getSQLType();
39583
- return typeof dimensions === "number" && dimensions > 0 ? `${elementType}${"[]".repeat(dimensions)}` : elementType;
39584
- };
39585
- var formatScalarDefault = (value) => {
39795
+ var formatDefault = (value) => {
39586
39796
  if (value === null || value === undefined)
39587
39797
  return "NULL";
39588
39798
  if (is(value, SQL))
@@ -39593,17 +39803,15 @@ var formatScalarDefault = (value) => {
39593
39803
  return value ? "true" : "false";
39594
39804
  if (typeof value === "number")
39595
39805
  return String(value);
39596
- if (typeof value === "object") {
39806
+ if (Array.isArray(value) || typeof value === "object") {
39597
39807
  return `'${JSON.stringify(value)}'::jsonb`;
39598
39808
  }
39599
39809
  return String(value);
39600
39810
  };
39601
- var formatArrayDefault = (values) => `ARRAY[${values.map((value) => Array.isArray(value) ? formatArrayDefault(value) : formatScalarDefault(value)).join(", ")}]`;
39602
- var formatDefault = (column, value) => {
39811
+ var columnSqlType = (column) => {
39603
39812
  const dimensions = Reflect.get(column, "dimensions");
39604
- if (Array.isArray(value) && typeof dimensions === "number" && dimensions > 0)
39605
- return `${formatArrayDefault(value)}::${columnSqlType(column)}`;
39606
- return formatScalarDefault(value);
39813
+ const elementType = column.getSQLType();
39814
+ return typeof dimensions === "number" && dimensions > 0 ? `${elementType}${"[]".repeat(dimensions)}` : elementType;
39607
39815
  };
39608
39816
  var columnSql = (column) => {
39609
39817
  const parts = [`"${column.name}"`, columnSqlType(column)];
@@ -39612,7 +39820,7 @@ var columnSql = (column) => {
39612
39820
  if (column.notNull && !column.primary)
39613
39821
  parts.push("NOT NULL");
39614
39822
  if (column.hasDefault && column.default !== undefined) {
39615
- parts.push(`DEFAULT ${formatDefault(column, column.default)}`);
39823
+ parts.push(`DEFAULT ${formatDefault(column.default)}`);
39616
39824
  }
39617
39825
  if (column.isUnique)
39618
39826
  parts.push("UNIQUE");
@@ -39730,6 +39938,14 @@ var mfaSmsAtomicChallengeMigration = {
39730
39938
  id: "0005_sms_atomic_challenge",
39731
39939
  sql: 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "sms_challenge_id" text;'
39732
39940
  };
39941
+ var mfaMultipleFactorsMigration = {
39942
+ id: "0006_multiple_factors",
39943
+ sql: [
39944
+ 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "mfa_factors" jsonb;',
39945
+ 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "sms_pending_factor_id" text;'
39946
+ ].join(`
39947
+ `)
39948
+ };
39733
39949
  var oidcResourceAudienceMigration = {
39734
39950
  id: "0002_resource_audience",
39735
39951
  sql: [
@@ -39811,7 +40027,8 @@ var blockMigrations = {
39811
40027
  mfaSmsColumnsMigration,
39812
40028
  mfaTotpLockoutMigration,
39813
40029
  mfaSmsDeliveryPolicyMigration,
39814
- mfaSmsAtomicChallengeMigration
40030
+ mfaSmsAtomicChallengeMigration,
40031
+ mfaMultipleFactorsMigration
39815
40032
  ]
39816
40033
  },
39817
40034
  oidc: {
@@ -40991,6 +41208,7 @@ export {
40991
41208
  generateTotp,
40992
41209
  generateTotpSecret,
40993
41210
  getCredentialStatus,
41211
+ getMfaFactors,
40994
41212
  getOrRefreshFederatedTokens,
40995
41213
  getRegisteredClient,
40996
41214
  getStatus,
@@ -41199,9 +41417,10 @@ export {
41199
41417
  webauthnRoutes,
41200
41418
  webhookDeliveriesTable,
41201
41419
  withAbsoluteNativeAuthClients,
41420
+ withMfaFactors,
41202
41421
  withSpan,
41203
41422
  writeWarrant
41204
41423
  };
41205
41424
 
41206
- //# debugId=DE81F9F326A0254664756E2164756E21
41425
+ //# debugId=3F694839DFE8077C64756E2164756E21
41207
41426
  //# sourceMappingURL=index.js.map