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 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();
    }
}