Configuring application authentication flows#

The authentication flows are used to configure the rules for user access to different applications. The authentication flows can be used to determine, for example, which applications should be available to which users, under what conditions two-factor authentication should be required, and which login validation methods a user can use. The use of authentication flows allows an organization to enforce its application access control policies.

Creation of authentication flows#

Authentication flows are managed in the “Authentication flows” section of the Blitz Identity Provider admin console.

:size=80%

Authentication flows settings screen

Creation of an authentication flow has following steps:

  1. Specifying the basic parameters of the flow:

    • flow ID;

    • flow description;

    • applications - a list of applications that will use the authentication flow .

Important

Only one flow can be created for each application. If no flow is created for a given application, the standard entry procedure (default authentication flow) will be applied to that application. If a flow is created without specifying applications, it will replace the standard authentication flow.

:size=80%

New authentication flows creation screen

  1. Writing the source code of the procedure. For successful operation of the authentication flow it is necessary to write a Java class that implements the necessary Strategy interface. All context information about the user, the current state of the authentication flow, etc. is available in the Context object. The flow consists of two blocks that define:

    • actions taken at the initial stage of the authentication process. In this block, for example, it is possible to define under what conditions to switch to the application in SSO mode (if the user has been previously authenticated);

    • actions taken after the initial authentication of the user. In this block, for example, you can define which two-factor authentication methods to use under which conditions.

  2. After writing the code, you should press the “Compile” button. If errors are detected, incorrect code fragments will be highlighted and signed with errors.

  3. If the compilation was successful you can save the flow.

  4. The saved procedure can be activated by clicking on the “Activate” button in the header of the corresponding procedure.

  5. Both activated and deactivated procedures can be edited. After editing, compile the procedure and then save it. If it has been activated, the new compiled flow will replace the old one.

Warning

If the procedure has been activated, only the compiled one can be saved. In other words, if an error while editing an activated flow has been detected, the “Save” button will not work and after reloading the page all changes will be lost.

:size=80%

Authentication flow procedure source code editing screen (excerpt)

Examples of Authentication flows#

The package includes several ready-made procedures that can be changed if necessary:

  • forced two-factor application authentication (Require2ndFactor);

  • limiting the list of available first factor methods when logging into the application (FFmethods);

  • granting access to the application only with a certain value of the attribute (AccessByAttribute);

  • prohibit logging into the application after the account expires (AccountExpiresCheck);

  • allow logging into the application only from specific networks (AllowedIPs);

  • prohibition of work in several simultaneous sessions (RestrictSessions);

  • saving a list of user groups in statements (claims) (AddGroupsToToken);

  • displaying an announcement to the user at login (InfoPipe);

  • request for user to enter attribute or actualize phone and email (PipeAttrActAdd);

  • registration of security key (WebAuthn, Passkey, FIDO2) at login (PipeWebAuthn).

Listings of these procedures are provided below. For ease of debugging, you can output information on the authentication state to the log using the logger.debug() function. For example, the following command will log the specified authentication level for a user:

logger.debug("requiredFactor="+ctx.userProps("requiredFactor"));

Forced two-factor application authentication#

The Require2ndFactor procedure requires two-factor authentication to access the application. If a user goes to the application within a single session, if there is one factor passed, the user will have the second factor additionally verified, i.e., SSO will not work in this case.

public class Require2ndFactor implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
        if(ctx.claims("subjectId") != null){
            if (ctx.sessionTrack().split(",").length < 2)
                return StrategyState.MORE(new String[]{});
            else
                return StrategyState.ENOUGH();
        }
        else {
            return StrategyState.MORE(new String[]{});
        }
    }

    @Override public StrategyState next(final Context ctx) {
        if(ctx.justCompletedFactor() == 1)
            return StrategyState.MORE(new String[]{});
        else
            return StrategyState.ENOUGH();
    }
}

Limiting the list of available first factor methods#

The FFmethods procedure allows to offer only certain identification and authentication methods to the user when entering the application (a similar procedure with a different list of methods can be assigned to another application). The procedure uses the following identifiers to designate the first factor authentication methods:

  • password - login using login and password;

  • x509 - login via electronic signature;

  • externalIdps - login via external identity providers (social networks);

  • spnego - login via operating system session;

  • sms - login via confirmation code from SMS.

  • knownDevice - login via known device;

  • qrCode - login via QR code;

  • webAuthn - login with security keys (WebAuthn, Passkey, FIDO2).

public class FFmethods implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
        if(ctx.claims("subjectId") != null)
            return StrategyState.ENOUGH();
        else
            return StrategyState.MORE(new String[]{"password","x509"});
    }

    @Override public StrategyState next(final Context ctx) {
        Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
        if(reqFactor == null || reqFactor == 0)
            return StrategyState.ENOUGH();
        else {
            if(reqFactor == ctx.justCompletedFactor())
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }
}

Allow logging in to the application only with a certain user’s attribute value#

The AccessByAttribute procedure uses the appList attribute to decide whether a user can access the application. This procedure requires the appList attribute to be created as an array (Array of strings). Application identifiers should be used as the values of the elements of this array. As a result, access to an application will be granted if among the values of appList a given user has the identifier of this application. This procedure architecture allows you to assign it to several applications at once and control access to them using a single attribute.

public class AccessByAttribute implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
        if(ctx.claims("subjectId") != null){
            int appListIdx = 0;
            boolean hasAccess = false;
            while (appListIdx > -1) {
                String app = ctx.claims("appList.[" + appListIdx + "]");
                logger.debug("app [" + appListIdx + "] = " + app);
                if (app == null){ appListIdx = -1; }
                else if (app.equals(ctx.appId())) { appListIdx = -1; hasAccess = true; }
                else { appListIdx ++; logger.debug("AppList index = " + appListIdx); }
            }
            if(hasAccess)
                return StrategyState.ENOUGH();
            else
                return StrategyState.DENY;
        }
        else
            return StrategyState.MORE(new String[]{});
    }

    @Override public StrategyState next(final Context ctx) {
        int appListIdx = 0;
        boolean hasAccess = false;
        while (appListIdx > -1) {
            String app = ctx.claims("appList.[" + appListIdx + "]");
            logger.debug("app [" + appListIdx + "] = " + app);
            if (app == null){ appListIdx = -1; }
            else if (app.equals(ctx.appId())) { appListIdx = -1; hasAccess = true; }
            else { appListIdx ++; logger.debug("AppList index = " + appListIdx); }
        }
        if(!hasAccess)
            return StrategyState.DENY;
        Integer reqFactor = 0;
        if (ctx.user() != null) {
            reqFactor = ctx.user().requiredFactor();
        }
        if (reqFactor == 0)
            return StrategyState.ENOUGH();
        else {
            if (reqFactor == ctx.justCompletedFactor())
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }
}

An example of a simplified version of the procedure is to grant a user access to an application provided his e-mail address is ivanov@company.ru:

@Override public StrategyBeginState begin(final Context ctx) {
  if(ctx.claims("subjectId") != null){
    if("ivanov@company.ru".equals(ctx.claims("email")))
      return StrategyState.ENOUGH();
    else
      return StrategyState.DENY;
  }
  else
    return StrategyState.MORE(new String[]{});
}

@Override public StrategyState next(final Context ctx) {
  if(!"ivanov@company.ru".equals(ctx.claims("email")))
    return StrategyState.DENY;
  Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
  if(reqFactor == null)
    return StrategyState.ENOUGH();
  else {
    if(reqFactor == ctx.justCompletedFactor())
      return StrategyState.ENOUGH();
    else
      return StrategyState.MORE(new String[]{});
  }
}

Prohibit logging into the application after the account expires#

The AccountExpiresCheck procedure uses the accountExpires attribute to decide whether a user has access to the application. For this procedure to work, you must create an attribute accountExpires with the type string (String). In this attribute it is necessary to store the date (in the format yyyy-MM-dd HH:mm, for example 2021-09-23 13:58), after which the access to the application will be blocked for this user. If the attribute value is not specified, the user will be allowed to enter the application.

public class AccountExpiresCheck implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

@Override public StrategyBeginState begin(final Context ctx) {
  if ("login".equals(ctx.prompt())){
    List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
    methods.remove("cls");
    return StrategyState.MORE(methods.toArray(new String[0]), true);
  } else {
    if(ctx.claims("subjectId") != null)
      return StrategyState.ENOUGH();
    else
      return StrategyState.MORE(new String[]{});
  }
}

@Override public StrategyState next(final Context ctx) {
  if (ctx.claims("accountExpires") != null && isExpired(ctx.claims("accountExpires")))
    return StrategyState.DENY("account_expired", true);
  Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
  if(reqFactor == null || reqFactor == ctx.justCompletedFactor())
    return StrategyState.ENOUGH();
  else
    return StrategyState.MORE(new String[]{});
}

public static boolean isExpired(String strData) {
  try {
    Date now = new Date();
            Date date = new SimpleDateFormat("yyyy-M-d HH:mm").parse(strData);
            return now.after(date);
        } catch (ParseException e) {
            throw new RuntimeException(e);
        }
    }
}

Allow logging into the application only from specific networks#

The AllowedIPs procedure uses the ALLOW_IP constant to decide whether the user can access the application. In this constant it is necessary to specify the list of networks from which the access to the application is possible, it is acceptable to specify several networks. When entering the application, the user’s IP address will be checked to see if it matches one of the values included in the constant. If it matches, the user will be allowed to enter the application, if it does not match - access will be denied.

public class AllowedIPs implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");
    private final static String[] ALLOW_IP = {"179.218","180.219"};

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override public StrategyState next(final Context ctx) {
        if (!_allowed_ip(ctx.ip())) {
            return StrategyState.DENY("ip_not_allowed", true);
        }
        Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
        if(reqFactor == null || reqFactor == ctx.justCompletedFactor()) {
            return StrategyState.ENOUGH_BUILDER()
                .build();
        } else
            return StrategyState.MORE(new String[]{});
    }

    private Boolean _allowed_ip(final String IP) {
      int IpListIdx = 0;
      boolean ipAllowed = false;
      while (IpListIdx > -1) {
        String ip_part = ALLOW_IP[IpListIdx];
        if (IP.startsWith(ip_part)) {
            ipAllowed = true;
            IpListIdx = -1;
        } else if (ALLOW_IP.length == (IpListIdx + 1)) {
            IpListIdx = -1;
        } else {
            IpListIdx ++;
        }
      }
        return ipAllowed;
    }
}

Prohibition of work in several simultaneous sessions#

The RestrictSessions procedure prohibits working in multiple sessions.

public class RestrictSessions implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
      List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
      if ("login".equals(ctx.prompt())){
        methods.remove("cls");
        return StrategyState.MORE(methods.toArray(new String[0]), true);
      } else {
        if(ctx.claims("subjectId") != null)
          return StrategyState.ENOUGH();
        else {
          methods.remove("cls");
          return StrategyState.MORE(methods.toArray(new String[0]));
        }
      }
    }

    @Override public StrategyState next(final Context ctx) {
      Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
      if(reqFactor == null || reqFactor == ctx.justCompletedFactor()) {
        return StrategyState.ENOUGH_BUILDER().singleSession(true).build();
      } else
        return StrategyState.MORE(new String[]{});
    }
}

Saving a list of user groups in statements (claims)#

The AddGroupsToToken procedure records a list of user groups in the grps statement. For this procedure to work, the conditions must be met:

When logging into the application, it will check if the user has groups in the memberOf attribute, and if they are present there, they will be added to the grps statement.

public class AddGroupsToToken implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override public StrategyState next(final Context ctx) {
        Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
        if(reqFactor == null || reqFactor == ctx.justCompletedFactor()) {
            List<String> grps = new ArrayList<String>();
            int groupListIdx = 0;
            while (groupListIdx > -1) {
              String group = ctx.claims("memberOf.[" + groupListIdx + "]");
              logger.debug("### group [" + groupListIdx + "] = " + group);
              if (group == null) {
                groupListIdx = -1;
              } else {
                grps.add(ctx.claims("memberOf.[" + groupListIdx + "]"));
                groupListIdx ++;
              }
            }
            LClaimsBuilder  claimsBuilder = ctx.claimsBuilder();
            if (grps.size() > 0) {
                claimsBuilder.addClaim("grps", grps);
            }
            LClaims claims = claimsBuilder.build();
            return StrategyState.ENOUGH_BUILDER()
                .withClaims(claims)
                .build();
        } else
            return StrategyState.MORE(new String[]{});
    }
}

Displaying an announcement to the user at login#

The InfoPipe procedure allows ads to be shown to the user at 30-day intervals when they log in. The following changes must be made to the procedure before it can be used:

  • in the requiredNews() function, adjust the criteria for displaying the ad - for example, in the example it is set to show once every 30 days if the user clicked the refuse button last time the ad was displayed;

  • in the showNews() function, instead of <BLITZ-HOST> specify the URI through which the Blitz Identity Provider is accessible from the user’s browser, and instead of <CLIENT_ID> specify the application identifier (with rights to scope openid) on behalf of which the auxiliary application will be executed;

  • set the notification type in the configuration file - see Settings for auxiliary applications (pipes);

  • set notification text and button names in messages - see Configuring auxiliary application messages (pipes).

public class InfoPipe implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override public StrategyState next(Context ctx) {
        if (ctx.user() == null || ctx.user().requiredFactor() == null ||
            ctx.user().requiredFactor().equals(ctx.justCompletedFactor()))
            if (requiredNews("user_agreement", ctx)) return showNews("user_agreement", ctx);
            else return StrategyState.ENOUGH();
        else
            return StrategyState.MORE(new String[] {});
    }

    private boolean requiredNews(final String pipeId, final Context ctx) {
        Long readOn = ctx.user().userProps().numProp("pipes.info." + pipeId + ".disagreedOn");
        return (readOn == null || Instant.now().getEpochSecond() - readOn > 30*86400);
    }

    private StrategyState showNews(final String pipeId, final Context ctx) {
        String uri = "https://<BLITZ-HOST>/blitz/pipes/info/start?&pipeId=" + pipeId;
        Set<String> claims = new HashSet<String>(){{
            add("instanceId");
        }};
        Set<String> scopes = new HashSet<String>(){{
            add("openid");
       }};
       return StrategyState.ENOUGH_BUILDER()
         .withPipe(uri, "<CLIENT_ID>", scopes, claims)
         .build();
    }
}

Request for user to enter attribute or actualize phone and email#

The PipeAttrActAdd procedure allows to request the user to enter the attribute value. For cell phone and for email, periodic updating of the contact is implemented. For regular attribute (in the example family_name is used) one-time filling of the attribute. In case the user did not want to fill the attribute, the next request to enter the attribute after a certain time will be realized.

The following modifications must be made to the procedure before use:

  • in the DOMAIN constant, specify the URI at which the Blitz Identity Provider is accessible from the user’s browser;

  • in the constants MOBILE_ATTR, EMAIL_ATTR, COMMON_ATTR specify the names of the attributes to be filled in;

  • in the SKIP_TIME_IN_SEC constant specify the time, not more often than which the user will be offered to fill the attribute;

  • in the ACT_TIME_IN_SEC constant specify the time, not more often than which the user will be offered to update phone or email;

  • in the ASK_AT_1ST_LOGIN constant, change the value if the request to fill in the contact should be performed at the first login (usually the first login occurs immediately after the account registration, so the setting is made so that the user is not prompted to fill in the data at the first login);

  • in the body of the procedure instead of _blitz_profile specify the identifier of another application, if the attributes change should be made from an application other than the user profile;

  • set texts in messages for attribute from COMMON_ATTR (default texts for email and phone can also be adjusted) - see Configuring auxiliary application messages (pipes).

public class PipeAttrActAdd implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");
    private final static String DOMAIN = "example.com";
    private final static String MOBILE_ATTR = "phone_number";
    private final static String EMAIL_ATTR = "email";
    private final static String COMMON_ATTR = "family_name";
    private final static Integer SKIP_TIME_IN_SEC = 30*86400;
    private final static Integer ACT_TIME_IN_SEC = 30*86400;
    private final static Boolean ASK_AT_1ST_LOGIN = false;

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override public StrategyState next(final Context ctx) {
        Instant instant = Instant.now();
        Boolean new_device = false;
        if (ctx.ua().getNewlyCreated() && ctx.justCompletedFactor() == 1 && !ASK_AT_1ST_LOGIN){
            logger.debug("User with sub={} is signing in, pid={}, on a new device",
                ctx.claims("subjectId"), ctx.id());
            new_device = true;
        }
        Integer reqFactor = ctx.user().requiredFactor();
        if(reqFactor == null || reqFactor == ctx.justCompletedFactor()) {
            Enough.Builder en_builder = StrategyState.ENOUGH_BUILDER();
            if (MOBILE_ATTR !=null && !new_device && requireActualizeAttr(MOBILE_ATTR, ctx)) {
                String uri = "https://"+DOMAIN+"/blitz/pipes/attr/act?attr="
                    +MOBILE_ATTR+"&canSkip=true&appId=_blitz_profile&verified=true";
                Set<String> clms = new HashSet<String>(){{
                    add("instanceId");
                    add(MOBILE_ATTR);
                }};
                Set<String> scps = new HashSet<String>(){{
                    add("openid");
                }};
                logger.debug("User has no {} or a non-actualzed {}, so opening pipe",
                    MOBILE_ATTR, MOBILE_ATTR);
                en_builder = en_builder.withPipe(uri, "_blitz_profile", scps, clms);
            } else if (EMAIL_ATTR !=null && !new_device && requireActualizeAttr(EMAIL_ATTR, ctx)) {
                String uri = "https://"+DOMAIN+"/blitz/pipes/attr/act?attr="
                    +EMAIL_ATTR+"&canSkip=true&appId=_blitz_profile&verified=true";
                Set<String> clms = new HashSet<String>(){{
                    add("instanceId");
                    add(EMAIL_ATTR);
                }};
                Set<String> scps = new HashSet<String>(){{
                    add("openid");
                }};
                logger.debug("User has no {} or a non-actualzed {}, so opening pipe",
                    EMAIL_ATTR, EMAIL_ATTR);
                en_builder = en_builder.withPipe(uri, "_blitz_profile", scps, clms);
            } else if (COMMON_ATTR !=null && !new_device &&
                       requireActualizeAttr(COMMON_ATTR, ctx)) {
                String uri = "https://"+DOMAIN+"/blitz/pipes/attr/act?attr="
                    +COMMON_ATTR+"&canSkip=true&appId=_blitz_profile";
                Set<String> clms = new HashSet<String>(){{
                    add("instanceId");
                    add(COMMON_ATTR);
                }};
                Set<String> scps = new HashSet<String>(){{
                    add("openid");
                }};
                logger.debug("User has no {}, so opening pipe", COMMON_ATTR);
                en_builder = en_builder.withPipe(uri, "_blitz_profile", scps, clms);
            }
            return en_builder.build();
        } else {
            return StrategyState.MORE(new String[]{});
        }
    }

    private Boolean requireActualizeAttr(final String attrName, final Context ctx) {
        if (attrName.equals(MOBILE_ATTR) && (ctx.passedTrack().startsWith("1:sms") ||
            ctx.passedTrack().endsWith("sms"))) {
            logger.debug("User subjectId = {}, pid = {} used SMS, so no actualization needed",
                ctx.claims("subjectId"), ctx.id());
            return false;
        }
        if (attrName.equals(EMAIL_ATTR) && ctx.passedTrack().endsWith("email")) {
            logger.debug(
                "User subjectId = {}, pid = {} used EMAIL while auth, so no actualization needed",
                ctx.claims("subjectId"), ctx.id());
            return false;
        }
        Long skpTime = null;
        Long actTime = null;
        long now = Instant.now().getEpochSecond();
        if (ctx.user().userProps().numProp("pipes.act."+attrName+".skippedOn") != null) {
            skpTime = ctx.user().userProps().numProp("pipes.act."+attrName+".skippedOn");
        }
        if (skpTime != null && ((now - skpTime) < SKIP_TIME_IN_SEC)) {
            logger.debug(
                "User subjectId = {}, pid = {} has skipped update '{}' only '{}' seconds ago, no actualization needed", ctx.claims("subjectId"), ctx.id(), attrName, (now - skpTime));
            return false;
        }
        if (ctx.claims(attrName) == null) return true;
        else {
            if (ctx.user().attrsCfmTimes() != null) {
                actTime = ctx.user().attrsCfmTimes().get(attrName);
            }
            if (actTime == null) return true;
            else {
                logger.debug(
                    "User subjectId = {}, pid = {} has updated '{}' '{}' seconds ago, actualization needed = {}", ctx.claims("subjectId"), ctx.id(), attrName, (now - actTime), ((now - actTime) > ACT_TIME_IN_SEC));
                return ((now - actTime) > ACT_TIME_IN_SEC);
            }
        }
    }
}

Registration of security key (WebAuthn, Passkey, FIDO2) at login#

The PipeWebAuthn procedure allows you to request the user to register a security key (WebAuthn, Passkey, FIDO2) at login.

The following modifications must be made to the procedure before use:

  • in the DOMAIN constant, specify the URI at which the Blitz Identity Provider is accessible from the user’s browser;

  • in the SKIP_TIME_IN_SEC constant specify the time, not more often than which the user will be offered to fill the attribute;

  • in the ASK_AT_1ST_LOGIN constant, change the value if the request for security key issuance should be performed at the first login (usually the first login occurs immediately after account registration, so the setting is made so that the user is not prompted to fill in the data at the first login);

  • in the body of the procedure instead of _blitz_profile specify the identifier of another application, if the attributes change should be made from an application other than the user profile.

public class PipeWebAuthn implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");
    private final static String DOMAIN = "example.com";
    private final static Integer SKIP_TIME_IN_SEC = 30*86400;
    private final static Boolean ASK_AT_1ST_LOGIN = true;

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override
    public StrategyState next(Context ctx) {
        Boolean new_device = false;
        if (ctx.ua().getNewlyCreated() && ctx.justCompletedFactor() == 1 && !ASK_AT_1ST_LOGIN){
            logger.debug("User with sub={} is signing in, pid={}, on a new device",
                ctx.claims("subjectId"), ctx.id());
            new_device = true;
        }
        if (ctx.user() == null || ctx.user().requiredFactor() == null ||
            ctx.user().requiredFactor().equals(ctx.justCompletedFactor()))
            if (!new_device && requiredWebAuthn(ctx))
                return webAuthn(ctx);
            else
                return StrategyState.ENOUGH();
        else
            return StrategyState.MORE(new String[] {});
    }

    private boolean requiredWebAuthn(final Context ctx) {
        LBrowser br = ctx.ua().asBrowser();
        String deviceType = br.getDeviceType();
        String os = br.getOsName();
        List<WakMeta> keyList = null;
        logger.trace("User subjectId = {}, pid = {} is logging using device '{}' and OS '{}', checking configured webAuthn keys", ctx.claims("subjectId"), ctx.id(), deviceType, os);
        ListResult<WakMeta> keys = ctx.dataSources().webAuthn().keysOfCurrentSubject();
        if (keys != null) {
            keyList = keys.filter(k -> deviceType.equals(k.addedOnUA().deviceType()))
                .filter(k -> os.equals(k.addedOnUA().osName())).list();
        }
        if (keys != null && keyList.size() > 0) {
            logger.debug("User subjectId = {}, pid = {} has '{}' webAuthn keys for device '{}' and OS '{}'", ctx.claims("subjectId"), ctx.id(), keyList.size(), deviceType, os);
            return false;
        } else {
            logger.debug("User subjectId = {}, pid = {} has no configured webAuthn keys for device '{}' and OS '{}'", ctx.claims("subjectId"), ctx.id(), deviceType, os);
        }
        Long disagreedOn = ctx.user().userProps().numProp("pipes.addKey." + deviceType + "." + os + ".disagreedOn");
        if (disagreedOn == null) {
            return true;
        } else if (Instant.now().getEpochSecond() - disagreedOn > SKIP_TIME_IN_SEC) {
            logger.debug("User subjectId = {}, pid = {} has skipped Webauthn '{}' seconds ago, so open webAuthn pipe", ctx.claims("subjectId"), ctx.id(), (Instant.now().getEpochSecond() - disagreedOn));
            return true;
        } else {
            logger.debug("User subjectId = {}, pid = {} has skipped Webauthn '{}' seconds ago, no need to open webAuthn pipe", ctx.claims("subjectId"), ctx.id(), (Instant.now().getEpochSecond() - disagreedOn));
            return false;
        }
    }

    private StrategyState webAuthn(final Context ctx) {
        String uri = "https://"+DOMAIN+"/blitz/pipes/conf/webAuthn/start?&canSkip=true&appId=_blitz_profile";
        Set<String> claims = new HashSet<String>(){{
            add("instanceId");
        }};
        Set<String> scopes = new HashSet<String>(){{
            add("openid");
        }};
        Map<String, Object> urParams = new HashMap<String, Object>();
        return StrategyState.ENOUGH_BUILDER()
            .withPipe(uri, "_blitz_profile", scopes, claims).build();
    }
}

Display a list of value selections to the user at login#

The ChoicePipe procedure allows the user to show the value list selection pages on login. The following changes must be made to the procedure before it can be used:

  • in the DOMAIN constant instead of <BLITZ-HOST> specify the URI where the Blitz Identity Provider is accessible from the user’s browser, and in the CLIENT_ID constant instead of <CLIENT_ID> specify the application identifier (with permissions to scope openid) on behalf of which the helper application will be executed;

  • set the notification type in the configuration file - see Settings for auxiliary applications (pipes);

  • set notification text and button names in messages - see Configuring auxiliary application messages (pipes).

public class ChoicePipe implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    private final static String DOMAIN = "<BLITZ-HOST>";
    private final static String CLIENT_ID = "<CLIENT_ID>";

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override
    public StrategyState next(Context ctx) {
        List<List<String>> choice = new ArrayList<List<String>>(){};
        choice.add(Arrays.asList("Value 1"));
        choice.add(Arrays.asList("Value 2"));
        try {
            if (ctx.user() == null || ctx.user().requiredFactor() == null
                    || ctx.user().requiredFactor().equals(ctx.justCompletedFactor())) {
                String res = new ObjectMapper().writeValueAsString(choice);
                String choiceJson = Base64.getUrlEncoder().encodeToString(res.getBytes("UTF-8"));
                return choice(ctx, choiceJson);
            }
            else
                return StrategyState.MORE(new String[] {});
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    private StrategyState choice(final Context ctx, final String choiceJson) {
        String uri = "https://" + DOMAIN + "/blitz/pipes/choice/start?appId=" + CLIENT_ID + "&pipeId=select_value&choices=" + choiceJson;
        Set<String> claims = new HashSet<String>(){{
            add("instanceId");
        }};
        Set<String> scopes = new HashSet<String>(){{
            add("openid");
        }};
       return StrategyState.ENOUGH_BUILDER()
         .withPipe(uri, CLIENT_ID, scopes, claims)
         .build();
    }
}