Step-up authentication with optional user-configured OTP

Hi

I have a Keycloak realm for which I have users that are able to set up their own OTP. When they have configured their OTP, they are requested this during login. Nothing special, this is the default browser flow.

Now, I have the following requirement:

  • If a user has configured OTP, they must provide it.
  • Some clients, handle sensitive data. Here, OTP is required during authentication. Users will need to configure it if not already done so.

First two things that come to mind is:

  • A separate authentication flow for these clients. But issues can arise since it’s an SSO. Switching applications will not enforce MFA due to the cookie.
  • Use step-up authentication and define mfa as an ACR the client can request, which maps to LoA = 2. This works generally, but is difficult to combine with the first requirement.

Let’s say we have the default step-up authentication flow as described in the docs:

Auth type                         | Requirement
---------------------------------------------------------------------------------
Cookie                             [x] Alternative  [ ] Required  [ ] Conditional
Auth Flow                          [x] Alternative  [ ] Required  [ ] Conditional
  | - Level 1 LoA                  [ ] Alternative  [ ] Required  [x] Conditional
       | - Condition: LoA = 1                                                    
       | - Username Password Form  [ ] Alternative  [x] Required                 
  | - Level 2 LoA                  [ ] Alternative  [ ] Required  [x] Conditional
       | - Condition: LoA = 2                                                    
       | - OTP Form                [ ] Alternative  [x] Required              

This does not request the user an OTP by default if they have it configured, like it does in the default browser flow. A first solution would be just adding the condition in the Level 1 LoA authentication flow:

Auth type                         | Requirement
---------------------------------------------------------------------------------
Cookie                             [x] Alternative  [ ] Required  [ ] Conditional
Auth Flow                          [x] Alternative  [ ] Required  [ ] Conditional
  | - Level 1 LoA                  [ ] Alternative  [ ] Required  [x] Conditional
       | - Condition: LoA = 1                                                    
       | - Username Password Form  [ ] Alternative  [x] Required                 
       | - Conditional OTP         [ ] Alternative  [ ] Required  [x] Conditional
            | - Condition: user configured                                       
            | - OTP Form           [ ] Alternative  [x] Required
  | - Level 2 LoA                  [ ] Alternative  [ ] Required  [x] Conditional
       | - Condition: LoA = 2                                                    
       | - OTP Form                [ ] Alternative  [x] Required                 

The problem here is that if a client immediately requests the mfa ACR, the user is requested to provide their OTP twice during authentication. Makes sense, since they go through both flows.

An attempted workaround is to split off the OTP form and create a conditional for either LoA = 2 or OTP configured as alternatives. If LoA = 2, there’s no need for an additional OTP request. If LoA = 1, the alternative conditional is skipped and it depends on user configuration:

Auth type                         | Requirement
---------------------------------------------------------------------------------
Cookie                             [x] Alternative  [ ] Required  [ ] Conditional
Auth Flow                          [x] Alternative  [ ] Required  [ ] Conditional
  | - Level 1 LoA                  [ ] Alternative  [ ] Required  [x] Conditional
       | - Condition: LoA = 1                                                    
       | - Username Password Form  [ ] Alternative  [x] Required                 
  | - Step-Up or MFA               [ ] Alternative  [x] Required  [ ] Conditional
       | - step-up-no-mfa          [x] Alternative  [ ] Required  [ ] Conditional
            | - Level 2 LoA        [ ] Alternative  [ ] Required  [x] Conditional
                 | - Condition: LoA = 2                                                    
                 | - OTP Form      [ ] Alternative  [x] Required                 
       | - no-step-up-mfa          [x] Alternative  [ ] Required  [ ] Conditional
            | - Conditional OTP    [ ] Alternative  [ ] Required  [x] Conditional
                 | - Condition: user configured                                       
                 | - OTP Form      [ ] Alternative  [x] Required

Now this works when a client requests LoA = 2. It also works if a client defaults to LoA = 1 and the user has OTP configured. However, it does not work if a client defaults to LoA = 1 and the user does not have OTP configured. This is because the Step-Up or MFA step is required, but neither step-up-no-mfa nor no-step-up-mfa alternatives will result in success.

So I don’t really find a way to configure the requirement of “required OTP if LoA = 2 OR configured by user”. Can this be configured without custom implementations or is this scenario too “exotic”?

Didi you try to use the “conditional otp form” for the no-stepup-mfa subflow instead of the OTP form?

I’ve tried various scenarios with it, unfortunately the end results are all the same. Specifically for the conditional OTP form I have no trigger for it to ask OTP (like the user attributes/roles in its config). If it also had a “user configured” condition, it might have worked. Then it could properly use the fallback of skip if it wasn’t configured for the user. Right now, there is no condition to request the OTP (unless I start using headers instead of ACR).

Having a “condition - user configured” together with “conditional OTP form”:

  • fallback skip && no otp configured: OK
  • fallback skip && otp configured: NOK => Because nothing forces the OTP request
  • fallback force && no otp configured: NOK => Because the user is forced to configure OTP
  • fallback force && otp configured: OK

Same goes for just having it as an alternative instead of inside a conditional.

Appreciate the idea though!

In case someone else also runs into this requirement:

I went with the idea of “if it had a user configured condition, it might have worked” and created a custom authenticator based off of the ConditionalOtpFormAuthenticator.

Removing all but the fallback config and adding a user configured config with the following check:

private OtpDecision voteForUserOtpConfigured(KeycloakSession session, UserModel user, Map<String, String> config) {
    if (!config.containsKey(OTP_USER_CONFIGURED_OTP)) {
        return ABSTAIN;
    }

    String otpConfiguredValue = config.get(OTP_USER_CONFIGURED_OTP);
    if (otpConfiguredValue == null) {
        return ABSTAIN;
    }

    boolean otpConfigured = user.credentialManager().isConfiguredFor(getCredentialProvider(session).getType());
    if ((otpConfiguredValue.equals(CONFIGURED) && otpConfigured)
        || (otpConfiguredValue.equals(NOT_CONFIGURED) && !otpConfigured)) {
        return SHOW_OTP;
    }

    return ABSTAIN;
}

It now forces OTP if the user has it configured and can fallback to skip if they don’t.

It passes my testing scenarios, but if it were possible without a custom authenticator I’d still be interested to hear.