Repository: logto-io/logto
File: packages/core/src/routes/experience/classes/mfa.ts (TypeScript)

Code before:
    assertThat(
      policy !== MfaPolicy.Mandatory &&
        !(await this.isMfaRequiredByUserOrganizations(mfaSettings, user.id)),
      new RequestError({
        code: 'session.mfa.mfa_policy_not_user_controlled',
        status: 422,
      })
    );

    this.#mfaSkipped = true;
  }

  /**
   * Mark the passkey binding as skipped and persist to user config.
   */
  skipPasskey() {
    this.#passkeySkipped = true;
  }

  /**
   * Skip the passkey suggestion for this interaction.
   * No persistence to user config.
   */
  skipPasskeySuggestion() {
    this.#passkeySuggestionSkipped = true;
  }

  /**
   * @throws {RequestError} with status 400 if the verification record is not verified
   * @throws {RequestError} with status 400 if the verification record has no secret
   * @throws {RequestError} with status 404 if the verification record is not found
   * @throws {RequestError} with status 400 if TOTP is not enabled in the sign-in experience
   * @throws {RequestError} with status 422 if the user already has a TOTP factor
   *
   * - Any existing TOTP factor will be replaced with the new one.
   */
  async addTotpByVerificationId(verificationId: string, log?: LogEntry) {
    const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
      VerificationType.TOTP,
      verificationId
    );

    log?.append({
      verification: verificationRecord.toJson(),
    });

    const bindTotp = verificationRecord.toBindMfa();

    await this.checkMfaFactorsEnabledInSignInExperience([MfaFactor.TOTP]);
    const { mfaVerifications } = await this.interactionContext.getIdentifiedUser();


Review comment (security):
Similarly, using a shared property like `skipPasskeyBinding` might be sufficient. When submitting the interaction, we can read this property and persist it to the database if the `interactionEvent` is signIn. This avoids introducing additional fields and keeps the skip binding behavior centralized in the interaction session as a single source of truth.

P.S. since the logic was implemented in the `mfa` class. Maybe a more accurate name is prefered like `passkeySignInBinding`, this will avoid confusion between mfaSkipped (WebAuthn binding ) vs passKey skipped.



Code after:

    this.#mfaSkipped = true;
  }

  /**
   * Mark the passkey binding as skipped and persist to user config.
   */
  skipPasskey() {
    this.#passkeySkipped = true;
  }

  /**
   * @throws {RequestError} with status 400 if the verification record is not verified
   * @throws {RequestError} with status 400 if the verification record has no secret
   * @throws {RequestError} with status 404 if the verification record is not found
   * @throws {RequestError} with status 400 if TOTP is not enabled in the sign-in experience
   * @throws {RequestError} with status 422 if the user already has a TOTP factor
   *
   * - Any existing TOTP factor will be replaced with the new one.
   */
  async addTotpByVerificationId(verificationId: string, log?: LogEntry) {
    const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
      VerificationType.TOTP,
      verificationId
    );

    log?.append({
      verification: verificationRecord.toJson(),
    });

    const bindTotp = verificationRecord.toBindMfa();

    await this.checkMfaFactorsEnabledInSignInExperience([MfaFactor.TOTP]);
    const { mfaVerifications } = await this.interactionContext.getIdentifiedUser();

    // A user can only bind one TOTP factor
    assertThat(
      mfaVerifications.every(({ type }) => type !== MfaFactor.TOTP),
      new RequestError({
        code: 'user.totp_already_in_use',
        status: 422,
      })
    );

    this.#totp = bindTotp;
  }

  /**
   * @throws {RequestError} with status 400 if the verification record is not verified
   * @throws {RequestError} with status 400 if the verification record has no registration data
   * @throws {RequestError} with status 404 if the verification record is not found