@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/server.js CHANGED
@@ -4368,8 +4368,8 @@ var failureResponse = (config, failure, requiredScopes) => new Response(JSON.str
4368
4368
  var agentAuthContextPlugin = (config) => new Elysia2({
4369
4369
  name: "@absolutejs/auth/agent-context",
4370
4370
  seed: pluginDependencySeed(config)
4371
- }).derive(({ request }) => {
4372
- const protectAgent = async (requiredScopes, handleAuth, handleAuthFail) => {
4371
+ }).derive(({ request }) => ({
4372
+ protectAgent: async (requiredScopes, handleAuth, handleAuthFail) => {
4373
4373
  if (config === undefined) {
4374
4374
  const failure = {
4375
4375
  code: "Unauthorized",
@@ -4395,9 +4395,8 @@ var agentAuthContextPlugin = (config) => new Elysia2({
4395
4395
  return await handleAuthFail?.(failure) ?? failureResponse(config, failure, requiredScopes);
4396
4396
  }
4397
4397
  return handleAuth(principal);
4398
- };
4399
- return { protectAgent };
4400
- }).as("global");
4398
+ }
4399
+ })).as("global");
4401
4400
 
4402
4401
  // src/agents/oauthGuide.ts
4403
4402
  var DEFAULT_GUIDE_ROUTE = "/auth.md";
@@ -5608,17 +5607,17 @@ var protectPermissionPlugin = ({
5608
5607
  }).use(sessionStore()).guard({
5609
5608
  cookie: t3.Cookie({ user_session_id: userSessionIdTypebox }),
5610
5609
  schema: "merge"
5611
- }).derive(({ store: { session }, cookie: { user_session_id }, status }) => {
5612
- const protectPermission = (check, handleAuth, handleAuthFail) => getStatusFromSource({
5610
+ }).derive(({ store: { session }, cookie: { user_session_id }, status }) => ({
5611
+ protectPermission: (check, handleAuth, handleAuthFail) => getStatusFromSource({
5613
5612
  authSessionStore,
5614
5613
  session,
5615
5614
  user_session_id
5616
5615
  }).then(async ({ user, error }) => {
5617
5616
  if (error) {
5618
- return await handleAuthFail?.(error) ?? status(error.code, error.message);
5617
+ return handleAuthFail?.(error) ?? status(error.code, error.message);
5619
5618
  }
5620
5619
  if (!user) {
5621
- return await handleAuthFail?.({
5620
+ return handleAuthFail?.({
5622
5621
  code: "Unauthorized",
5623
5622
  message: "User is not authenticated"
5624
5623
  }) ?? status("Unauthorized", "User is not authenticated");
@@ -5635,15 +5634,14 @@ var protectPermissionPlugin = ({
5635
5634
  organizationId: check.organizationId,
5636
5635
  type: "authorization_denied"
5637
5636
  });
5638
- return await handleAuthFail?.({
5637
+ return handleAuthFail?.({
5639
5638
  code: "Forbidden",
5640
5639
  message: "Insufficient permissions"
5641
5640
  }) ?? status("Forbidden", "Insufficient permissions");
5642
5641
  }
5643
5642
  return handleAuth(user);
5644
- });
5645
- return { protectPermission };
5646
- }).as("global");
5643
+ })
5644
+ })).as("global");
5647
5645
 
5648
5646
  // src/routes/protectRoute.ts
5649
5647
  import { Elysia as Elysia6, t as t4 } from "elysia";
@@ -5707,7 +5705,8 @@ var protectRoutePlugin = ({
5707
5705
  }).derive(async ({
5708
5706
  store: { session },
5709
5707
  cookie: { user_session_id },
5710
- headers
5708
+ headers,
5709
+ status
5711
5710
  }) => {
5712
5711
  const sessionStatus = await getStatusFromSource({
5713
5712
  authSessionStore,
@@ -5726,30 +5725,25 @@ var protectRoutePlugin = ({
5726
5725
  authorization: headers.authorization,
5727
5726
  config: accessTokens
5728
5727
  });
5729
- return { absoluteAuthStatus: sessionStatus, authPrincipal };
5730
- }).derive(({ absoluteAuthStatus, authPrincipal, status }) => {
5731
- const protectRoute = async (handleAuth, handleAuthFail) => {
5732
- if (!absoluteAuthStatus)
5733
- return await handleAuthFail?.({
5734
- code: "Unauthorized",
5735
- message: "User is not authenticated"
5736
- }) ?? status("Unauthorized", "User is not authenticated");
5737
- const user = authPrincipal?.user;
5738
- const { error } = absoluteAuthStatus;
5739
- if (error) {
5740
- if (user)
5741
- return handleAuth(user);
5742
- return await handleAuthFail?.(error) ?? status(error.code, error.message);
5743
- }
5744
- if (!user) {
5745
- return await handleAuthFail?.({
5746
- code: "Unauthorized",
5747
- message: "User is not authenticated"
5748
- }) ?? status("Unauthorized", "User is not authenticated");
5728
+ return {
5729
+ authPrincipal,
5730
+ protectRoute: (handleAuth, handleAuthFail) => {
5731
+ const user = authPrincipal?.user;
5732
+ const { error } = sessionStatus;
5733
+ if (error) {
5734
+ if (user)
5735
+ return handleAuth(user);
5736
+ return handleAuthFail?.(error) ?? status(error.code, error.message);
5737
+ }
5738
+ if (!user) {
5739
+ return handleAuthFail?.({
5740
+ code: "Unauthorized",
5741
+ message: "User is not authenticated"
5742
+ }) ?? status("Unauthorized", "User is not authenticated");
5743
+ }
5744
+ return handleAuth(user);
5749
5745
  }
5750
- return handleAuth(user);
5751
5746
  };
5752
- return { protectRoute };
5753
5747
  }).as("global");
5754
5748
 
5755
5749
  // src/routes/stepUp.ts
@@ -5762,24 +5756,23 @@ var stepUpPlugin = ({
5762
5756
  }).use(sessionStore()).guard({
5763
5757
  cookie: t5.Cookie({ user_session_id: userSessionIdTypebox }),
5764
5758
  schema: "merge"
5765
- }).derive(({ store: { session }, cookie: { user_session_id }, status }) => {
5766
- const requireRecentAuth = (maxAgeMs, handleAuth, handleAuthFail) => loadSessionFromSource({
5759
+ }).derive(({ store: { session }, cookie: { user_session_id }, status }) => ({
5760
+ requireRecentAuth: (maxAgeMs, handleAuth, handleAuthFail) => loadSessionFromSource({
5767
5761
  authSessionStore,
5768
5762
  session,
5769
5763
  userSessionId: user_session_id.value
5770
- }).then(async (userSession) => {
5764
+ }).then((userSession) => {
5771
5765
  const authenticatedAt = userSession?.authenticatedAt;
5772
5766
  const isRecent = authenticatedAt !== undefined && Date.now() - authenticatedAt <= maxAgeMs;
5773
5767
  if (!userSession || !isRecent) {
5774
- return await handleAuthFail?.({
5768
+ return handleAuthFail?.({
5775
5769
  code: "Unauthorized",
5776
5770
  message: "Recent authentication required"
5777
5771
  }) ?? status("Unauthorized", "Recent authentication required");
5778
5772
  }
5779
5773
  return handleAuth(userSession.user);
5780
- });
5781
- return { requireRecentAuth };
5782
- }).as("global");
5774
+ })
5775
+ })).as("global");
5783
5776
 
5784
5777
  // src/oidc/socketTickets.ts
5785
5778
  init_crypto();
@@ -7190,7 +7183,45 @@ var createLockoutGuard = ({
7190
7183
  });
7191
7184
 
7192
7185
  // src/mfa/types.ts
7193
- var isMfaEnrolled = (enrollment) => enrollment !== undefined && (enrollment.totpVerified || enrollment.smsVerified || enrollment.backupCodeHashes.length > 0);
7186
+ var LEGACY_SMS_FACTOR_ID = "legacy-sms";
7187
+ var LEGACY_TOTP_FACTOR_ID = "legacy-totp";
7188
+ var getMfaFactors = (enrollment) => {
7189
+ if (enrollment.factors !== undefined)
7190
+ return enrollment.factors;
7191
+ const factors = [];
7192
+ if (enrollment.totpSecretCiphertext) {
7193
+ factors.push({
7194
+ id: LEGACY_TOTP_FACTOR_ID,
7195
+ label: "Authenticator app",
7196
+ secretCiphertext: enrollment.totpSecretCiphertext,
7197
+ type: "totp",
7198
+ verified: enrollment.totpVerified
7199
+ });
7200
+ }
7201
+ if (enrollment.smsPhone) {
7202
+ factors.push({
7203
+ id: LEGACY_SMS_FACTOR_ID,
7204
+ label: "Text message",
7205
+ phone: enrollment.smsPhone,
7206
+ type: "sms",
7207
+ verified: enrollment.smsVerified
7208
+ });
7209
+ }
7210
+ return factors;
7211
+ };
7212
+ var isMfaEnrolled = (enrollment) => enrollment !== undefined && (getMfaFactors(enrollment).some((factor) => factor.verified) || enrollment.backupCodeHashes.length > 0);
7213
+ var withMfaFactors = (enrollment, factors) => {
7214
+ const firstSms = factors.find((factor) => factor.type === "sms");
7215
+ const firstTotp = factors.find((factor) => factor.type === "totp");
7216
+ return {
7217
+ ...enrollment,
7218
+ factors,
7219
+ smsPhone: firstSms?.phone,
7220
+ smsVerified: firstSms?.verified ?? false,
7221
+ totpSecretCiphertext: firstTotp?.secretCiphertext,
7222
+ totpVerified: firstTotp?.verified ?? false
7223
+ };
7224
+ };
7194
7225
 
7195
7226
  // src/mfa/gate.ts
7196
7227
  var createMfaGate = ({ getUserId, mfaStore }) => async (user) => isMfaEnrolled(await mfaStore.getEnrollment(getUserId(user)));
@@ -7406,6 +7437,8 @@ var hasRecentAuthentication = (session, maxAgeMs, now = Date.now()) => session.a
7406
7437
  // src/mfa/sms.ts
7407
7438
  var DECIMAL_RADIX2 = 10;
7408
7439
  var MASK_VISIBLE_DIGITS = 4;
7440
+ var FACTOR_LABEL_MAX_LENGTH = 80;
7441
+ var DEFAULT_SMS_LABEL = "Text message";
7409
7442
  var E164_PATTERN = /^\+[1-9]\d{7,14}$/u;
7410
7443
  var generateNumericCode = (length) => {
7411
7444
  const bytes = new Uint8Array(length);
@@ -7579,10 +7612,13 @@ var mfaSmsRoutes = ({
7579
7612
  smsSetupRoute = "/auth/mfa/sms/setup",
7580
7613
  smsVerifyRoute = "/auth/mfa/sms/verify"
7581
7614
  }) => new Elysia18().use(sessionStore()).post(smsSetupRoute, {
7582
- body: t12.Object({ phone: t12.String() }),
7615
+ body: t12.Object({
7616
+ label: t12.Optional(t12.String({ maxLength: FACTOR_LABEL_MAX_LENGTH })),
7617
+ phone: t12.String()
7618
+ }),
7583
7619
  cookie: t12.Cookie({ user_session_id: userSessionIdTypebox })
7584
7620
  }, async ({
7585
- body: { phone },
7621
+ body: { label, phone },
7586
7622
  cookie: { user_session_id },
7587
7623
  status,
7588
7624
  store: { session }
@@ -7610,21 +7646,35 @@ var mfaSmsRoutes = ({
7610
7646
  if (existing?.smsCodeSentAt !== undefined && now - existing.smsCodeSentAt < smsResendCooldownMs) {
7611
7647
  return status("Too Many Requests", "SMS resend cooldown active");
7612
7648
  }
7649
+ const base = existing ?? {
7650
+ backupCodeHashes: [],
7651
+ createdAt: now,
7652
+ smsVerified: false,
7653
+ totpVerified: false,
7654
+ updatedAt: now,
7655
+ userId
7656
+ };
7657
+ const factor = {
7658
+ id: crypto.randomUUID(),
7659
+ label: label?.trim() || DEFAULT_SMS_LABEL,
7660
+ phone,
7661
+ type: "sms",
7662
+ verified: false
7663
+ };
7664
+ const factors = [
7665
+ ...getMfaFactors(base).filter((existingFactor) => existingFactor.type !== "sms" || existingFactor.verified),
7666
+ factor
7667
+ ];
7613
7668
  try {
7614
7669
  await issueAndStoreSmsCode({
7615
7670
  codeLength: smsCodeLength,
7616
7671
  enrollment: {
7617
- backupCodeHashes: existing?.backupCodeHashes ?? [],
7618
- createdAt: existing?.createdAt ?? now,
7619
- lastUsedAt: existing?.lastUsedAt,
7672
+ ...withMfaFactors(base, factors),
7620
7673
  smsFailedAttempts: 0,
7674
+ smsPendingFactorId: factor.id,
7621
7675
  smsPhone: phone,
7622
- smsVerified: false,
7623
- totpFailedAttempts: existing?.totpFailedAttempts ?? 0,
7624
- totpSecretCiphertext: existing?.totpSecretCiphertext,
7625
- totpVerified: existing?.totpVerified ?? false,
7626
7676
  updatedAt: now,
7627
- userId
7677
+ userId: base.userId
7628
7678
  },
7629
7679
  mfaStore,
7630
7680
  onSendSmsCode,
@@ -7641,12 +7691,18 @@ var mfaSmsRoutes = ({
7641
7691
  throw error;
7642
7692
  return status(mapped.status, mapped.message);
7643
7693
  }
7644
- return status("OK", { phone: maskPhone(phone) });
7694
+ return status("OK", {
7695
+ factorId: factor.id,
7696
+ phone: maskPhone(phone)
7697
+ });
7645
7698
  }).post(smsVerifyRoute, {
7646
- body: t12.Object({ code: t12.String() }),
7699
+ body: t12.Object({
7700
+ code: t12.String(),
7701
+ factorId: t12.Optional(t12.String())
7702
+ }),
7647
7703
  cookie: t12.Cookie({ user_session_id: userSessionIdTypebox })
7648
7704
  }, async ({
7649
- body: { code },
7705
+ body: { code, factorId },
7650
7706
  cookie: { user_session_id },
7651
7707
  status,
7652
7708
  store: { session }
@@ -7667,6 +7723,12 @@ var mfaSmsRoutes = ({
7667
7723
  if (!enrollment?.smsPhone) {
7668
7724
  return status("Bad Request", "No SMS enrollment in progress");
7669
7725
  }
7726
+ const factors = getMfaFactors(enrollment);
7727
+ const pendingFactorId = factorId ?? enrollment.smsPendingFactorId;
7728
+ const pendingFactor = factors.find((candidate) => candidate.type === "sms" && (candidate.id === pendingFactorId || pendingFactorId === undefined && candidate.phone === enrollment.smsPhone));
7729
+ if (!pendingFactor || pendingFactor.type !== "sms") {
7730
+ return status("Bad Request", "No SMS enrollment in progress");
7731
+ }
7670
7732
  const localCodeHash = enrollment.smsPendingCodeHash;
7671
7733
  const challengeId = enrollment.smsChallengeId;
7672
7734
  const localCodeExpiresAt = enrollment.smsPendingCodeExpiresAt;
@@ -7694,7 +7756,7 @@ var mfaSmsRoutes = ({
7694
7756
  purpose: "mfa_enrollment",
7695
7757
  reference: providerReference,
7696
7758
  subject: userId,
7697
- to: enrollment.smsPhone
7759
+ to: pendingFactor.phone
7698
7760
  });
7699
7761
  if (checked.error !== undefined) {
7700
7762
  return status(checked.error.status, checked.error.message);
@@ -7710,8 +7772,10 @@ var mfaSmsRoutes = ({
7710
7772
  });
7711
7773
  return status(providerResult?.status === "max_attempts_reached" || attempts === undefined ? "Too Many Requests" : "Bad Request", providerResult?.status === "expired" ? "SMS code expired" : "Invalid SMS code");
7712
7774
  }
7775
+ const verifiedFactors = factors.map((candidate) => candidate.id === pendingFactor.id ? { ...candidate, verified: true } : candidate);
7713
7776
  const completed = await mfaStore.completeSmsChallenge({
7714
7777
  challengeId,
7778
+ factors: verifiedFactors,
7715
7779
  smsVerified: true,
7716
7780
  userId
7717
7781
  });
@@ -7741,15 +7805,50 @@ var mfaChallenge = ({
7741
7805
  smsMaxAttempts = DEFAULT_SMS_MAX_ATTEMPTS,
7742
7806
  smsResendCooldownMs = DEFAULT_SMS_RESEND_COOLDOWN_MS,
7743
7807
  totpMaxAttempts = DEFAULT_TOTP_MAX_ATTEMPTS
7744
- }) => new Elysia19().use(sessionStore()).post(challengeRoute, {
7808
+ }) => new Elysia19().use(sessionStore()).get(challengeRoute, { cookie: t13.Cookie({ user_session_id: userSessionIdTypebox }) }, async ({
7809
+ cookie: { user_session_id },
7810
+ status,
7811
+ store: { unregisteredSession }
7812
+ }) => {
7813
+ const compatibilityLayer = await createSessionCompatibilityLayer({
7814
+ authSessionStore,
7815
+ userSessionId: user_session_id.value
7816
+ });
7817
+ const challengeUnregistered = authSessionStore ? compatibilityLayer.unregisteredSession : unregisteredSession;
7818
+ const pendingId = user_session_id.value;
7819
+ const pending = pendingId ? challengeUnregistered[pendingId] : undefined;
7820
+ if (!pending) {
7821
+ return status("Unauthorized", "No MFA challenge in progress");
7822
+ }
7823
+ const user = await getChallengeUser(pending.userIdentity ?? {});
7824
+ const enrollment = user ? await mfaStore.getEnrollment(getUserId(user)) : undefined;
7825
+ if (!enrollment) {
7826
+ return status("Unauthorized", "No MFA challenge in progress");
7827
+ }
7828
+ const response = {
7829
+ backupCodesAvailable: enrollment.backupCodeHashes.length > 0,
7830
+ factors: getMfaFactors(enrollment).filter((factor) => factor.verified).map((factor) => ({
7831
+ id: factor.id,
7832
+ label: factor.label,
7833
+ phone: factor.type === "sms" ? maskPhone(factor.phone) : null,
7834
+ type: factor.type
7835
+ }))
7836
+ };
7837
+ return status("OK", response);
7838
+ }).post(challengeRoute, {
7745
7839
  body: t13.Object({
7746
7840
  action: t13.Optional(t13.Union([t13.Literal("send"), t13.Literal("verify")])),
7747
7841
  code: t13.Optional(t13.String()),
7748
- factor: t13.Optional(t13.Literal("sms"))
7842
+ factor: t13.Optional(t13.Union([
7843
+ t13.Literal("backup_codes"),
7844
+ t13.Literal("sms"),
7845
+ t13.Literal("totp")
7846
+ ])),
7847
+ factorId: t13.Optional(t13.String())
7749
7848
  }),
7750
7849
  cookie: t13.Cookie({ user_session_id: userSessionIdTypebox })
7751
7850
  }, async ({
7752
- body: { action, code, factor },
7851
+ body: { action, code, factor, factorId },
7753
7852
  cookie: { user_session_id },
7754
7853
  status,
7755
7854
  store: { session, unregisteredSession }
@@ -7770,6 +7869,7 @@ var mfaChallenge = ({
7770
7869
  if (!user || !enrollment) {
7771
7870
  return status("Unauthorized", "No MFA challenge in progress");
7772
7871
  }
7872
+ const factors = getMfaFactors(enrollment);
7773
7873
  const promote = async () => {
7774
7874
  delete challengeUnregistered[pendingId];
7775
7875
  const userSessionId = crypto.randomUUID();
@@ -7792,10 +7892,18 @@ var mfaChallenge = ({
7792
7892
  if (enrollment.smsCodeSentAt !== undefined && Date.now() - enrollment.smsCodeSentAt < smsResendCooldownMs) {
7793
7893
  return status("Too Many Requests", "SMS resend cooldown active");
7794
7894
  }
7895
+ const smsFactor = factors.find((candidate) => candidate.type === "sms" && candidate.verified && (candidate.id === factorId || factorId === undefined));
7896
+ if (!smsFactor || smsFactor.type !== "sms") {
7897
+ return status("Bad Request", "SMS factor not found");
7898
+ }
7795
7899
  try {
7796
7900
  await issueAndStoreSmsCode({
7797
7901
  codeLength: smsCodeLength,
7798
- enrollment,
7902
+ enrollment: {
7903
+ ...withMfaFactors(enrollment, factors),
7904
+ smsPendingFactorId: smsFactor.id,
7905
+ smsPhone: smsFactor.phone
7906
+ },
7799
7907
  mfaStore,
7800
7908
  onSendSmsCode,
7801
7909
  previousEnrollment: enrollment,
@@ -7811,10 +7919,19 @@ var mfaChallenge = ({
7811
7919
  throw error;
7812
7920
  return status(mapped.status, mapped.message);
7813
7921
  }
7814
- return status("OK", { status: "sent" });
7922
+ return status("OK", {
7923
+ factorId: smsFactor.id,
7924
+ phone: maskPhone(smsFactor.phone),
7925
+ status: "sent"
7926
+ });
7815
7927
  };
7816
7928
  const runSmsChallenge = async () => {
7817
- if (!enrollment.smsVerified || !enrollment.smsPhone) {
7929
+ if (action !== "send" && factorId !== undefined && enrollment.smsPendingFactorId !== undefined && factorId !== enrollment.smsPendingFactorId) {
7930
+ return status("Bad Request", "No SMS code in progress");
7931
+ }
7932
+ const selectedFactorId = enrollment.smsPendingFactorId ?? factorId;
7933
+ const smsFactor = factors.find((candidate) => candidate.type === "sms" && candidate.verified && (candidate.id === selectedFactorId || selectedFactorId === undefined));
7934
+ if (!smsFactor || smsFactor.type !== "sms") {
7818
7935
  return status("Unauthorized", "No MFA challenge in progress");
7819
7936
  }
7820
7937
  if (action === "send")
@@ -7853,7 +7970,7 @@ var mfaChallenge = ({
7853
7970
  purpose: "mfa_challenge",
7854
7971
  reference: providerReference,
7855
7972
  subject: getUserId(user),
7856
- to: enrollment.smsPhone
7973
+ to: smsFactor.phone
7857
7974
  });
7858
7975
  if (checked.error !== undefined) {
7859
7976
  return status(checked.error.status, checked.error.message);
@@ -7875,6 +7992,7 @@ var mfaChallenge = ({
7875
7992
  }
7876
7993
  const completed = await mfaStore.completeSmsChallenge({
7877
7994
  challengeId,
7995
+ factors,
7878
7996
  lastUsedAt: Date.now(),
7879
7997
  smsVerified: true,
7880
7998
  userId: getUserId(user)
@@ -7896,11 +8014,14 @@ var mfaChallenge = ({
7896
8014
  });
7897
8015
  return status("Unauthorized", "Too many attempts");
7898
8016
  }
7899
- const totpValid = enrollment.totpVerified && enrollment.totpSecretCiphertext ? await verifyTotp({
7900
- secret: await decryptTotpSecret(enrollment.totpSecretCiphertext, encryptionKey),
8017
+ const totpFactors = factors.filter((candidate) => candidate.type === "totp" && candidate.verified && (candidate.id === factorId || factorId === undefined));
8018
+ const totpChecks = factor === "backup_codes" ? [] : await Promise.all(totpFactors.map(async (candidate) => verifyTotp({
8019
+ secret: await decryptTotpSecret(candidate.secretCiphertext, encryptionKey),
7901
8020
  token: code
7902
- }) : false;
7903
- const remainingBackupHashes = totpValid ? undefined : await consumeBackupCode(code, enrollment.backupCodeHashes);
8021
+ })));
8022
+ const totpValid = totpChecks.some(Boolean);
8023
+ const mayUseBackupCode = factor === "backup_codes" || factor === undefined && factorId === undefined;
8024
+ const remainingBackupHashes = totpValid || !mayUseBackupCode ? undefined : await consumeBackupCode(code, enrollment.backupCodeHashes);
7904
8025
  if (!totpValid && remainingBackupHashes === undefined) {
7905
8026
  await mfaStore.saveEnrollment({
7906
8027
  ...enrollment,
@@ -7932,6 +8053,12 @@ var maskPhone2 = (phone) => {
7932
8053
  const suffix = phone.slice(-visibleDigits);
7933
8054
  return `${"*".repeat(Math.max(0, phone.length - visibleDigits))}${suffix}`;
7934
8055
  };
8056
+ var publicFactors = (enrollment) => enrollment ? getMfaFactors(enrollment).filter((factor) => factor.verified).map((factor) => ({
8057
+ id: factor.id,
8058
+ label: factor.label,
8059
+ phone: factor.type === "sms" ? maskPhone2(factor.phone) : null,
8060
+ type: factor.type
8061
+ })) : [];
7935
8062
  var mfaManagementRoutes = ({
7936
8063
  authSessionStore,
7937
8064
  getUserId,
@@ -7955,6 +8082,7 @@ var mfaManagementRoutes = ({
7955
8082
  const response = {
7956
8083
  backupCodesRemaining: enrollment?.backupCodeHashes.length ?? 0,
7957
8084
  enabled: isMfaEnrolled(enrollment),
8085
+ factors: publicFactors(enrollment),
7958
8086
  smsBackup: {
7959
8087
  enabled: enrollment?.smsVerified ?? false,
7960
8088
  phone: maskPhone2(enrollment?.smsPhone)
@@ -7980,11 +8108,54 @@ var mfaManagementRoutes = ({
7980
8108
  }
7981
8109
  await mfaStore.removeEnrollment(getUserId(userSession.user));
7982
8110
  return status("OK", { status: "disabled" });
8111
+ }).delete(`${managementRoute}/factors/:factorId`, {
8112
+ cookie: t14.Cookie({ user_session_id: userSessionIdTypebox }),
8113
+ params: t14.Object({ factorId: t14.String() })
8114
+ }, async ({
8115
+ cookie: { user_session_id },
8116
+ params: { factorId },
8117
+ status,
8118
+ store: { session }
8119
+ }) => {
8120
+ const userSession = await loadSessionFromSource({
8121
+ authSessionStore,
8122
+ session,
8123
+ userSessionId: user_session_id.value
8124
+ });
8125
+ if (!userSession) {
8126
+ return status("Unauthorized", "Authentication required");
8127
+ }
8128
+ if (!hasRecentAuthentication(userSession, managementAuthMaxAgeMs)) {
8129
+ return status("Unauthorized", "Recent authentication required");
8130
+ }
8131
+ const userId = getUserId(userSession.user);
8132
+ const enrollment = await mfaStore.getEnrollment(userId);
8133
+ if (!enrollment) {
8134
+ return status("Not Found", "MFA factor not found");
8135
+ }
8136
+ const factors = getMfaFactors(enrollment);
8137
+ if (!factors.some((factor) => factor.id === factorId)) {
8138
+ return status("Not Found", "MFA factor not found");
8139
+ }
8140
+ const remaining = factors.filter((factor) => factor.id !== factorId);
8141
+ if (remaining.length === 0) {
8142
+ await mfaStore.removeEnrollment(userId);
8143
+ } else {
8144
+ const hasVerifiedFactor = remaining.some((factor) => factor.verified);
8145
+ await mfaStore.saveEnrollment(withMfaFactors({
8146
+ ...enrollment,
8147
+ backupCodeHashes: hasVerifiedFactor ? enrollment.backupCodeHashes : [],
8148
+ updatedAt: Date.now()
8149
+ }, remaining));
8150
+ }
8151
+ return status("OK", { status: "removed" });
7983
8152
  });
7984
8153
 
7985
8154
  // src/mfa/totp.ts
7986
8155
  init_crypto();
7987
8156
  import { Elysia as Elysia21, t as t15 } from "elysia";
8157
+ var FACTOR_LABEL_MAX_LENGTH2 = 80;
8158
+ var DEFAULT_TOTP_LABEL = "Authenticator app";
7988
8159
  var mfaTotpRoutes = ({
7989
8160
  authSessionStore,
7990
8161
  backupCodeCount = DEFAULT_BACKUP_CODE_COUNT,
@@ -7996,7 +8167,13 @@ var mfaTotpRoutes = ({
7996
8167
  onMfaEnrolled,
7997
8168
  totpSetupRoute = "/auth/mfa/totp/setup",
7998
8169
  totpVerifyRoute = "/auth/mfa/totp/verify"
7999
- }) => new Elysia21().use(sessionStore()).post(totpSetupRoute, { cookie: t15.Cookie({ user_session_id: userSessionIdTypebox }) }, async ({
8170
+ }) => new Elysia21().use(sessionStore()).post(totpSetupRoute, {
8171
+ body: t15.Object({
8172
+ label: t15.Optional(t15.String({ maxLength: FACTOR_LABEL_MAX_LENGTH2 }))
8173
+ }),
8174
+ cookie: t15.Cookie({ user_session_id: userSessionIdTypebox })
8175
+ }, async ({
8176
+ body: { label },
8000
8177
  cookie: { user_session_id },
8001
8178
  status,
8002
8179
  store: { session }
@@ -8016,20 +8193,25 @@ var mfaTotpRoutes = ({
8016
8193
  const secret = generateTotpSecret();
8017
8194
  const existing = await mfaStore.getEnrollment(userId);
8018
8195
  const now = Date.now();
8019
- await mfaStore.saveEnrollment({
8020
- backupCodeHashes: existing?.backupCodeHashes ?? [],
8021
- createdAt: existing?.createdAt ?? now,
8022
- smsFailedAttempts: existing?.smsFailedAttempts,
8023
- smsPendingCodeExpiresAt: existing?.smsPendingCodeExpiresAt,
8024
- smsPendingCodeHash: existing?.smsPendingCodeHash,
8025
- smsPhone: existing?.smsPhone,
8026
- smsVerified: existing?.smsVerified ?? false,
8027
- totpSecretCiphertext: await encryptTotpSecret(secret, encryptionKey),
8196
+ const base = existing ?? {
8197
+ backupCodeHashes: [],
8198
+ createdAt: now,
8199
+ smsVerified: false,
8028
8200
  totpVerified: false,
8029
8201
  updatedAt: now,
8030
8202
  userId
8031
- });
8203
+ };
8204
+ const factor = {
8205
+ id: crypto.randomUUID(),
8206
+ label: label?.trim() || DEFAULT_TOTP_LABEL,
8207
+ secretCiphertext: await encryptTotpSecret(secret, encryptionKey),
8208
+ type: "totp",
8209
+ verified: false
8210
+ };
8211
+ const factors = getMfaFactors(base).filter((existingFactor) => existingFactor.type !== "totp" || existingFactor.verified);
8212
+ await mfaStore.saveEnrollment(withMfaFactors({ ...base, updatedAt: now }, [...factors, factor]));
8032
8213
  return status("OK", {
8214
+ factorId: factor.id,
8033
8215
  secret,
8034
8216
  uri: createTotpKeyUri({
8035
8217
  accountName: userId,
@@ -8038,10 +8220,13 @@ var mfaTotpRoutes = ({
8038
8220
  })
8039
8221
  });
8040
8222
  }).post(totpVerifyRoute, {
8041
- body: t15.Object({ code: t15.String() }),
8223
+ body: t15.Object({
8224
+ code: t15.String(),
8225
+ factorId: t15.Optional(t15.String())
8226
+ }),
8042
8227
  cookie: t15.Cookie({ user_session_id: userSessionIdTypebox })
8043
8228
  }, async ({
8044
- body: { code },
8229
+ body: { code, factorId },
8045
8230
  cookie: { user_session_id },
8046
8231
  status,
8047
8232
  store: { session }
@@ -8059,21 +8244,26 @@ var mfaTotpRoutes = ({
8059
8244
  }
8060
8245
  const userId = getUserId(userSession.user);
8061
8246
  const enrollment = await mfaStore.getEnrollment(userId);
8062
- if (!enrollment?.totpSecretCiphertext) {
8247
+ if (!enrollment) {
8248
+ return status("Bad Request", "No TOTP enrollment in progress");
8249
+ }
8250
+ const factors = getMfaFactors(enrollment);
8251
+ const factor = factors.find((candidate) => candidate.type === "totp" && (candidate.id === factorId || factorId === undefined && !candidate.verified));
8252
+ if (!factor || factor.type !== "totp") {
8063
8253
  return status("Bad Request", "No TOTP enrollment in progress");
8064
8254
  }
8065
- const secret = await decryptTotpSecret(enrollment.totpSecretCiphertext, encryptionKey);
8255
+ const secret = await decryptTotpSecret(factor.secretCiphertext, encryptionKey);
8066
8256
  const valid = await verifyTotp({ secret, token: code });
8067
8257
  if (!valid) {
8068
8258
  return status("Bad Request", "Invalid TOTP code");
8069
8259
  }
8070
8260
  const { codes, hashes } = await generateBackupCodes(backupCodeCount);
8071
- await mfaStore.saveEnrollment({
8261
+ const verifiedFactors = factors.map((candidate) => candidate.id === factor.id ? { ...candidate, verified: true } : candidate);
8262
+ await mfaStore.saveEnrollment(withMfaFactors({
8072
8263
  ...enrollment,
8073
8264
  backupCodeHashes: hashes,
8074
- totpVerified: true,
8075
8265
  updatedAt: Date.now()
8076
- });
8266
+ }, verifiedFactors));
8077
8267
  await onMfaEnrolled?.({ userId });
8078
8268
  return status("OK", { backupCodes: codes });
8079
8269
  });
@@ -35948,23 +36138,33 @@ var ensureReadable = async (ciphertext, key, userId) => {
35948
36138
  }
35949
36139
  };
35950
36140
  var planEnrollment = async (enrollment, oldKey, newKey) => {
35951
- const ciphertext = enrollment.totpSecretCiphertext;
35952
- if (ciphertext === undefined || ciphertext.length === 0) {
36141
+ const factors = getMfaFactors(enrollment);
36142
+ const totpFactors = factors.filter((factor) => factor.type === "totp");
36143
+ if (totpFactors.length === 0) {
35953
36144
  const plan2 = { kind: "skip" };
35954
36145
  return plan2;
35955
36146
  }
35956
- const secret = await tryDecrypt(ciphertext, oldKey);
35957
- if (secret === undefined) {
35958
- await ensureReadable(ciphertext, newKey, enrollment.userId);
36147
+ let rotated = false;
36148
+ const nextFactors = await Promise.all(factors.map(async (factor) => {
36149
+ if (factor.type !== "totp")
36150
+ return factor;
36151
+ const secret = await tryDecrypt(factor.secretCiphertext, oldKey);
36152
+ if (secret === undefined) {
36153
+ await ensureReadable(factor.secretCiphertext, newKey, enrollment.userId);
36154
+ return factor;
36155
+ }
36156
+ rotated = true;
36157
+ return {
36158
+ ...factor,
36159
+ secretCiphertext: await encryptTotpSecret(secret, newKey)
36160
+ };
36161
+ }));
36162
+ if (!rotated) {
35959
36163
  const plan2 = { kind: "already" };
35960
36164
  return plan2;
35961
36165
  }
35962
36166
  const plan = {
35963
- enrollment: {
35964
- ...enrollment,
35965
- totpSecretCiphertext: await encryptTotpSecret(secret, newKey),
35966
- updatedAt: Date.now()
35967
- },
36167
+ enrollment: withMfaFactors({ ...enrollment, updatedAt: Date.now() }, nextFactors),
35968
36168
  kind: "rotate"
35969
36169
  };
35970
36170
  return plan;
@@ -35987,7 +36187,8 @@ var rotateMfaEncryptionKey = async ({
35987
36187
  // src/mfa/inMemoryMfaStore.ts
35988
36188
  var cloneEnrollment = (value) => ({
35989
36189
  ...value,
35990
- backupCodeHashes: [...value.backupCodeHashes]
36190
+ backupCodeHashes: [...value.backupCodeHashes],
36191
+ factors: value.factors?.map((factor) => ({ ...factor }))
35991
36192
  });
35992
36193
  var createInMemoryMfaStore = () => {
35993
36194
  const enrollments = new Map;
@@ -36009,6 +36210,7 @@ var createInMemoryMfaStore = () => {
36009
36210
  },
36010
36211
  completeSmsChallenge: async ({
36011
36212
  challengeId,
36213
+ factors,
36012
36214
  lastUsedAt,
36013
36215
  smsVerified,
36014
36216
  userId
@@ -36018,11 +36220,13 @@ var createInMemoryMfaStore = () => {
36018
36220
  return false;
36019
36221
  enrollments.set(userId, cloneEnrollment({
36020
36222
  ...current,
36223
+ factors: factors ?? current.factors,
36021
36224
  lastUsedAt: lastUsedAt ?? current.lastUsedAt,
36022
36225
  smsChallengeId: undefined,
36023
36226
  smsFailedAttempts: 0,
36024
36227
  smsPendingCodeExpiresAt: undefined,
36025
36228
  smsPendingCodeHash: undefined,
36229
+ smsPendingFactorId: undefined,
36026
36230
  smsPendingPurpose: undefined,
36027
36231
  smsProviderReference: undefined,
36028
36232
  smsVerified,
@@ -36083,6 +36287,7 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
36083
36287
  backup_code_hashes: jsonb("backup_code_hashes").$type().notNull().default([]),
36084
36288
  created_at_ms: bigint("created_at_ms", { mode: "number" }).notNull(),
36085
36289
  last_used_at_ms: bigint("last_used_at_ms", { mode: "number" }),
36290
+ mfa_factors: jsonb("mfa_factors").$type(),
36086
36291
  sms_challenge_id: text("sms_challenge_id"),
36087
36292
  sms_code_sent_at_ms: bigint("sms_code_sent_at_ms", { mode: "number" }),
36088
36293
  sms_failed_attempts: smallint("sms_failed_attempts").notNull().default(0),
@@ -36090,6 +36295,7 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
36090
36295
  mode: "number"
36091
36296
  }),
36092
36297
  sms_pending_code_hash: text("sms_pending_code_hash"),
36298
+ sms_pending_factor_id: text("sms_pending_factor_id"),
36093
36299
  sms_pending_purpose: text("sms_pending_purpose").$type(),
36094
36300
  sms_phone: varchar("sms_phone", { length: PHONE_LENGTH }),
36095
36301
  sms_provider_reference: text("sms_provider_reference"),
@@ -36103,12 +36309,14 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
36103
36309
  var toEnrollment = (row) => ({
36104
36310
  backupCodeHashes: row.backup_code_hashes,
36105
36311
  createdAt: row.created_at_ms,
36312
+ factors: row.mfa_factors ?? undefined,
36106
36313
  lastUsedAt: row.last_used_at_ms ?? undefined,
36107
36314
  smsChallengeId: row.sms_challenge_id ?? undefined,
36108
36315
  smsCodeSentAt: row.sms_code_sent_at_ms ?? undefined,
36109
36316
  smsFailedAttempts: row.sms_failed_attempts,
36110
36317
  smsPendingCodeExpiresAt: row.sms_pending_code_expires_at_ms ?? undefined,
36111
36318
  smsPendingCodeHash: row.sms_pending_code_hash ?? undefined,
36319
+ smsPendingFactorId: row.sms_pending_factor_id ?? undefined,
36112
36320
  smsPendingPurpose: row.sms_pending_purpose ?? undefined,
36113
36321
  smsPhone: row.sms_phone ?? undefined,
36114
36322
  smsProviderReference: row.sms_provider_reference ?? undefined,
@@ -36125,11 +36333,13 @@ var createPostgresMfaStore = (db) => {
36125
36333
  backup_code_hashes: enrollment.backupCodeHashes,
36126
36334
  created_at_ms: enrollment.createdAt,
36127
36335
  last_used_at_ms: enrollment.lastUsedAt ?? null,
36336
+ mfa_factors: enrollment.factors ?? null,
36128
36337
  sms_challenge_id: enrollment.smsChallengeId ?? null,
36129
36338
  sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
36130
36339
  sms_failed_attempts: enrollment.smsFailedAttempts ?? 0,
36131
36340
  sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
36132
36341
  sms_pending_code_hash: enrollment.smsPendingCodeHash ?? null,
36342
+ sms_pending_factor_id: enrollment.smsPendingFactorId ?? null,
36133
36343
  sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
36134
36344
  sms_phone: enrollment.smsPhone ?? null,
36135
36345
  sms_provider_reference: enrollment.smsProviderReference ?? null,
@@ -36152,11 +36362,13 @@ var createPostgresMfaStore = (db) => {
36152
36362
  });
36153
36363
  const rows = await db.insert(mfaEnrollmentsTable).values(values).onConflictDoUpdate({
36154
36364
  set: {
36365
+ mfa_factors: enrollment.factors ?? null,
36155
36366
  sms_challenge_id: challengeId,
36156
36367
  sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
36157
36368
  sms_failed_attempts: 0,
36158
36369
  sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
36159
36370
  sms_pending_code_hash: null,
36371
+ sms_pending_factor_id: enrollment.smsPendingFactorId ?? null,
36160
36372
  sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
36161
36373
  sms_phone: enrollment.smsPhone ?? null,
36162
36374
  sms_provider_reference: null,
@@ -36170,16 +36382,19 @@ var createPostgresMfaStore = (db) => {
36170
36382
  },
36171
36383
  completeSmsChallenge: async ({
36172
36384
  challengeId,
36385
+ factors,
36173
36386
  lastUsedAt,
36174
36387
  smsVerified,
36175
36388
  userId
36176
36389
  }) => {
36177
36390
  const rows = await db.update(mfaEnrollmentsTable).set({
36178
36391
  last_used_at_ms: lastUsedAt,
36392
+ mfa_factors: factors,
36179
36393
  sms_challenge_id: null,
36180
36394
  sms_failed_attempts: 0,
36181
36395
  sms_pending_code_expires_at_ms: null,
36182
36396
  sms_pending_code_hash: null,
36397
+ sms_pending_factor_id: null,
36183
36398
  sms_pending_purpose: null,
36184
36399
  sms_provider_reference: null,
36185
36400
  sms_verified: smsVerified,
@@ -40573,12 +40788,7 @@ var renderChunk = (chunk) => {
40573
40788
  return "";
40574
40789
  };
40575
40790
  var formatSqlTemplate = (value) => value.queryChunks.map(renderChunk).join("");
40576
- var columnSqlType = (column) => {
40577
- const dimensions = Reflect.get(column, "dimensions");
40578
- const elementType = column.getSQLType();
40579
- return typeof dimensions === "number" && dimensions > 0 ? `${elementType}${"[]".repeat(dimensions)}` : elementType;
40580
- };
40581
- var formatScalarDefault = (value) => {
40791
+ var formatDefault = (value) => {
40582
40792
  if (value === null || value === undefined)
40583
40793
  return "NULL";
40584
40794
  if (is(value, SQL))
@@ -40589,17 +40799,15 @@ var formatScalarDefault = (value) => {
40589
40799
  return value ? "true" : "false";
40590
40800
  if (typeof value === "number")
40591
40801
  return String(value);
40592
- if (typeof value === "object") {
40802
+ if (Array.isArray(value) || typeof value === "object") {
40593
40803
  return `'${JSON.stringify(value)}'::jsonb`;
40594
40804
  }
40595
40805
  return String(value);
40596
40806
  };
40597
- var formatArrayDefault = (values) => `ARRAY[${values.map((value) => Array.isArray(value) ? formatArrayDefault(value) : formatScalarDefault(value)).join(", ")}]`;
40598
- var formatDefault = (column, value) => {
40807
+ var columnSqlType = (column) => {
40599
40808
  const dimensions = Reflect.get(column, "dimensions");
40600
- if (Array.isArray(value) && typeof dimensions === "number" && dimensions > 0)
40601
- return `${formatArrayDefault(value)}::${columnSqlType(column)}`;
40602
- return formatScalarDefault(value);
40809
+ const elementType = column.getSQLType();
40810
+ return typeof dimensions === "number" && dimensions > 0 ? `${elementType}${"[]".repeat(dimensions)}` : elementType;
40603
40811
  };
40604
40812
  var columnSql = (column) => {
40605
40813
  const parts = [`"${column.name}"`, columnSqlType(column)];
@@ -40608,7 +40816,7 @@ var columnSql = (column) => {
40608
40816
  if (column.notNull && !column.primary)
40609
40817
  parts.push("NOT NULL");
40610
40818
  if (column.hasDefault && column.default !== undefined) {
40611
- parts.push(`DEFAULT ${formatDefault(column, column.default)}`);
40819
+ parts.push(`DEFAULT ${formatDefault(column.default)}`);
40612
40820
  }
40613
40821
  if (column.isUnique)
40614
40822
  parts.push("UNIQUE");
@@ -40726,6 +40934,14 @@ var mfaSmsAtomicChallengeMigration = {
40726
40934
  id: "0005_sms_atomic_challenge",
40727
40935
  sql: 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "sms_challenge_id" text;'
40728
40936
  };
40937
+ var mfaMultipleFactorsMigration = {
40938
+ id: "0006_multiple_factors",
40939
+ sql: [
40940
+ 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "mfa_factors" jsonb;',
40941
+ 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "sms_pending_factor_id" text;'
40942
+ ].join(`
40943
+ `)
40944
+ };
40729
40945
  var oidcResourceAudienceMigration = {
40730
40946
  id: "0002_resource_audience",
40731
40947
  sql: [
@@ -40807,7 +41023,8 @@ var blockMigrations = {
40807
41023
  mfaSmsColumnsMigration,
40808
41024
  mfaTotpLockoutMigration,
40809
41025
  mfaSmsDeliveryPolicyMigration,
40810
- mfaSmsAtomicChallengeMigration
41026
+ mfaSmsAtomicChallengeMigration,
41027
+ mfaMultipleFactorsMigration
40811
41028
  ]
40812
41029
  },
40813
41030
  oidc: {
@@ -41849,5 +42066,5 @@ export {
41849
42066
  userSessionIdTypebox
41850
42067
  };
41851
42068
 
41852
- //# debugId=AF5D9E4F453A58D064756E2164756E21
42069
+ //# debugId=EBB1CE453260D04564756E2164756E21
41853
42070
  //# sourceMappingURL=server.js.map