Skip to content

Instantly share code, notes, and snippets.

@thomasdarimont
Last active January 20, 2026 15:54
Show Gist options
  • Select an option

  • Save thomasdarimont/ad3aa0e36d33d067dba2 to your computer and use it in GitHub Desktop.

Select an option

Save thomasdarimont/ad3aa0e36d33d067dba2 to your computer and use it in GitHub Desktop.
Keycloak Conditional OTP Step-by-Step

Conditional OTP authentication:

Scenario Setup

Run Keycloak with the custom authentication provider.

Create a new realm dynamic-otp-test.

Create a new realm role require_otp_auth.

Create a new test user otp

Goto Authentication -> Flows -> Select Browser.

Click on copy

Name the new flow browser dynamic otp

Click on actions in the line Browser Dynamic Otp Forms

Add execution: Conditional OTP Form.

Disable the OTP Form

Mark the Conditional OTP Form as required.

Click on Actions -> configure for the Conditional OTP Form

Give it the alias Conditional OTP Authentication

Select the require_otp_role from the Force OTP for Role

Configure the Fallback OTP handling to skip

Goto Bindings

Select browser dynamic otp for the browser flow

Scenario Test

As the user otp with no role assigned

Try login to the account application (tipp: use incognito mode / private browsing)

Enter username / password

Register OTP device.

Logout

Login again

... try the various options of the Conditional OTP Authenticator.

I recommend the chrome ModHeader Plugin to test the header based patterns.

package org.keycloak.authentication.authenticators.browser;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import javax.ws.rs.core.MultivaluedMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.OtpDecision.*;
import static org.keycloak.models.utils.KeycloakModelUtils.getRoleFromString;
import static org.keycloak.models.utils.KeycloakModelUtils.hasRole;
/**
* An {@link OTPFormAuthenticator} that can conditionally require OTP authentication.
* <p>
* <p>
* The decision for whether or not to require OTP authentication can be made based on multiple conditions
* which are evaluated in the following order. The first matching condition determines the outcome.
* </p>
* <ol>
* <li>User Attribute</li>
* <li>Role</li>
* <li>Request Header</li>
* <li>Configured Default</li>
* </ol>
* <p>
* If no condition matches, the {@link ConditionalOtpFormAuthenticator} fallback is to require OTP authentication.
* </p>
* <p>
* <h2>User Attribute</h2>
* A User Attribute like <code>otp_auth</code> can be used to control OTP authentication on individual user level.
* The supported values are <i>skip</i> and <i>force</i>. If the value is set to <i>skip</i> then the OTP auth is skipped for the user,
* otherwise if the value is <i>force</i> then the OTP auth is enforced. The setting is ignored for any other value.
* </p>
* <p>
* <h2>Role</h2>
* A role can be used to control the OTP authentication. If the user has the specified role the OTP authentication is forced.
* Otherwise if no role is selected the setting is ignored.
* <p>
* </p>
* <p>
* <h2>Request Header</h2>
* <p>
* Request Headers are matched via regex {@link Pattern}s and can be specified as a whitelist and blacklist.
* <i>No OTP for Header</i> specifies the pattern for which OTP authentication <b>is not</b> required.
* This can be used to specify trusted networks, e.g. via: <code>X-Forwarded-Host: (1.2.3.4|1.2.3.5)</code> where
* The IPs 1.2.3.4, 1.2.3.5 denote trusted machines.
* <i>Force OTP for Header</i> specifies the pattern for which OTP authentication <b>is</b> required. Whitelist entries take
* precedence before blacklist entries.
* </p>
* <p>
* <h2>Configured Default</h2>
* A default fall-though behaviour can be specified to handle cases where all previous conditions did not lead to a conclusion.
* An OTP authentication is required in case no default is configured.
* </p>
*
* @author <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
*/
public class ConditionalOtpFormAuthenticator extends OTPFormAuthenticator {
public static final String SKIP = "skip";
public static final String FORCE = "force";
public static final String OTP_CONTROL_USER_ATTRIBUTE = "otpControlAttribute";
public static final String FORCE_OTP_ROLE = "forceOtpRole";
public static final String NO_OTP_REQUIRED_FOR_HTTP_HEADER = "noOtpRequiredForHeaderPattern";
public static final String FORCE_OTP_FOR_HTTP_HEADER = "forceOtpForHeaderPattern";
public static final String DEFAULT_OTP_OUTCOME = "defaultOtpOutcome";
enum OtpDecision {
SKIP_OTP, SHOW_OTP, ABSTAIN
}
@Override
public void authenticate(AuthenticationFlowContext context) {
Map<String, String> config = context.getAuthenticatorConfig().getConfig();
if (tryConcludeBasedOn(voteForUserOtpControlAttribute(context, config), context)) {
return;
}
if (tryConcludeBasedOn(voteForUserForceOtpRole(context, config), context)) {
return;
}
if (tryConcludeBasedOn(voteForHttpHeaderMatchesPattern(context, config), context)) {
return;
}
if (tryConcludeBasedOn(voteForDefaultFallback(context, config), context)) {
return;
}
showOtpForm(context);
}
private OtpDecision voteForDefaultFallback(AuthenticationFlowContext context, Map<String, String> config) {
if (!config.containsKey(DEFAULT_OTP_OUTCOME)) {
return ABSTAIN;
}
switch (config.get(DEFAULT_OTP_OUTCOME)) {
case SKIP:
return SKIP_OTP;
case FORCE:
return SHOW_OTP;
default:
return ABSTAIN;
}
}
private boolean tryConcludeBasedOn(OtpDecision state, AuthenticationFlowContext context) {
switch (state) {
case SHOW_OTP:
showOtpForm(context);
return true;
case SKIP_OTP:
context.success();
return true;
default:
return false;
}
}
private void showOtpForm(AuthenticationFlowContext context) {
super.authenticate(context);
}
private OtpDecision voteForUserOtpControlAttribute(AuthenticationFlowContext context, Map<String, String> config) {
if (!config.containsKey(OTP_CONTROL_USER_ATTRIBUTE)) {
return ABSTAIN;
}
String attributeName = config.get(OTP_CONTROL_USER_ATTRIBUTE);
if (attributeName == null) {
return ABSTAIN;
}
List<String> values = context.getUser().getAttribute(attributeName);
if (values.isEmpty()) {
return ABSTAIN;
}
String value = values.get(0).trim();
switch (value) {
case SKIP:
return SKIP_OTP;
case FORCE:
return SHOW_OTP;
default:
return ABSTAIN;
}
}
private OtpDecision voteForHttpHeaderMatchesPattern(AuthenticationFlowContext context, Map<String, String> config) {
if (!config.containsKey(FORCE_OTP_FOR_HTTP_HEADER) && !config.containsKey(NO_OTP_REQUIRED_FOR_HTTP_HEADER)) {
return ABSTAIN;
}
MultivaluedMap<String, String> requestHeaders = context.getHttpRequest().getHttpHeaders().getRequestHeaders();
//Inverted to allow white-lists, e.g. for specifying trusted remote hosts: X-Forwarded-Host: (1.2.3.4|1.2.3.5)
if (containsMatchingRequestHeader(requestHeaders, config.get(NO_OTP_REQUIRED_FOR_HTTP_HEADER))) {
return SKIP_OTP;
}
if (containsMatchingRequestHeader(requestHeaders, config.get(FORCE_OTP_FOR_HTTP_HEADER))) {
return SHOW_OTP;
}
return ABSTAIN;
}
private boolean containsMatchingRequestHeader(MultivaluedMap<String, String> requestHeaders, String headerPattern) {
if (headerPattern == null) {
return false;
}
//TODO cache RequestHeader Patterns
//TODO how to deal with pattern syntax exceptions?
Pattern pattern = Pattern.compile(headerPattern, Pattern.DOTALL);
for (Map.Entry<String, List<String>> entry : requestHeaders.entrySet()) {
String key = entry.getKey();
for (String value : entry.getValue()) {
String headerEntry = key.trim() + ": " + value.trim();
if (pattern.matcher(headerEntry).matches()) {
return true;
}
}
}
return false;
}
private OtpDecision voteForUserForceOtpRole(AuthenticationFlowContext context, Map<String, String> config) {
if (!config.containsKey(FORCE_OTP_ROLE)) {
return ABSTAIN;
}
RoleModel forceOtpRole = getRoleFromString(context.getRealm(), config.get(FORCE_OTP_ROLE));
UserModel user = context.getUser();
if (hasRole(user.getRoleMappings(), forceOtpRole)) {
return SHOW_OTP;
}
return ABSTAIN;
}
}
package org.keycloak.authentication.authenticators.browser;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.List;
import static java.util.Arrays.asList;
import static org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticator.*;
import static org.keycloak.provider.ProviderConfigProperty.*;
/**
* An {@link AuthenticatorFactory} for {@link ConditionalOtpFormAuthenticator}s.
*
* @author <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
*/
public class ConditionalOtpFormAuthenticatorFactory implements AuthenticatorFactory {
public static final String PROVIDER_ID = "auth-conditional-otp-form";
public static final ConditionalOtpFormAuthenticator SINGLETON = new ConditionalOtpFormAuthenticator();
public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.OPTIONAL,
AuthenticationExecutionModel.Requirement.DISABLED};
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
@Override
public void init(Config.Scope config) {
//NOOP
}
@Override
public void postInit(KeycloakSessionFactory factory) {
//NOOP
}
@Override
public void close() {
//NOOP
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getReferenceCategory() {
return UserCredentialModel.TOTP;
}
@Override
public boolean isConfigurable() {
return true;
}
@Override
public boolean isUserSetupAllowed() {
return true;
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public String getDisplayType() {
return "Conditional OTP Form";
}
@Override
public String getHelpText() {
return "Validates a OTP on a separate OTP form. Only shown if required based on the configured conditions.";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
ProviderConfigProperty forceOtpUserAttribute = new ProviderConfigProperty();
forceOtpUserAttribute.setType(STRING_TYPE);
forceOtpUserAttribute.setName(OTP_CONTROL_USER_ATTRIBUTE);
forceOtpUserAttribute.setLabel("OTP control User Attribute");
forceOtpUserAttribute.setHelpText("The name of the user attribute to explicitly control OTP auth. " +
"If attribute value is 'force' then OTP is always required. " +
"If value is 'skip' the OTP auth is skipped. Otherwise this check is ignored.");
ProviderConfigProperty forceOtpRole = new ProviderConfigProperty();
forceOtpRole.setType(ROLE_TYPE);
forceOtpRole.setName(FORCE_OTP_ROLE);
forceOtpRole.setLabel("Force OTP for Role");
forceOtpRole.setHelpText("OTP is always required if user has the given Role.");
ProviderConfigProperty noOtpRequiredForHttpHeader = new ProviderConfigProperty();
noOtpRequiredForHttpHeader.setType(STRING_TYPE);
noOtpRequiredForHttpHeader.setName(NO_OTP_REQUIRED_FOR_HTTP_HEADER);
noOtpRequiredForHttpHeader.setLabel("No OTP for Header");
noOtpRequiredForHttpHeader.setHelpText("OTP required if a HTTP request header does not match the given pattern." +
"Can be used to specify trusted networks via: X-Forwarded-Host: (1.2.3.4|1.2.3.5)." +
"In this case requests from 1.2.3.4 and 1.2.3.5 come from a trusted source.");
noOtpRequiredForHttpHeader.setDefaultValue("");
ProviderConfigProperty forceOtpForHttpHeader = new ProviderConfigProperty();
forceOtpForHttpHeader.setType(STRING_TYPE);
forceOtpForHttpHeader.setName(FORCE_OTP_FOR_HTTP_HEADER);
forceOtpForHttpHeader.setLabel("Force OTP for Header");
forceOtpForHttpHeader.setHelpText("OTP required if a HTTP request header matches the given pattern.");
forceOtpForHttpHeader.setDefaultValue("");
ProviderConfigProperty defaultOutcome = new ProviderConfigProperty();
defaultOutcome.setType(LIST_TYPE);
defaultOutcome.setName(DEFAULT_OTP_OUTCOME);
defaultOutcome.setLabel("Fallback OTP handling");
defaultOutcome.setDefaultValue(asList(SKIP, FORCE));
defaultOutcome.setHelpText("What to do in case of every check abstains. Defaults to force OTP authentication.");
return asList(forceOtpUserAttribute, forceOtpRole, noOtpRequiredForHttpHeader, forceOtpForHttpHeader, defaultOutcome);
}
}
// Used in various role mappers
public static RoleModel getRoleFromString(RealmModel realm, String roleName) {
String[] parsedRole = parseRole(roleName);
RoleModel role = null;
if (parsedRole[0] == null) {
role = realm.getRole(parsedRole[1]);
} else {
ClientModel client = realm.getClientByClientId(parsedRole[0]);
if (client != null) {
role = client.getRole(parsedRole[1]);
}
}
return role;
}
// Used for hardcoded role mappers
public static String[] parseRole(String role) {
int scopeIndex = role.lastIndexOf('.');
if (scopeIndex > -1) {
String appName = role.substring(0, scopeIndex);
role = role.substring(scopeIndex + 1);
String[] rtn = {appName, role};
return rtn;
} else {
String[] rtn = {null, role};
return rtn;
}
}
org.keycloak.authentication.authenticators.browser.ConditionalOtpFormAuthenticatorFactory
@EduhCosta
Copy link

How I can edit the UI or Template for OTP form on keycloak theme?

@atulmahind
Copy link

Hi Thomas,

I have this setup configured on earlier version of Keyclaok and it worked fine. Recently, I upgraded to 10.0.1 and this workflow breaks down.
When I try to login after setting up the local Keyclok, I get:

13:30:36,766 WARN  [org.keycloak.services] (default task-17) KC-SERVICES0013: Failed authentication: org.keycloak.authentication.AuthenticationFlowException
	at org.keycloak.keycloak-services@10.0.1//org.keycloak.authentication.AuthenticationProcessor.authenticationAction(AuthenticationProcessor.java:942)
	at org.keycloak.keycloak-services@10.0.1//org.keycloak.services.resources.LoginActionsService.processFlow(LoginActionsService.java:311)
	at org.keycloak.keycloak-services@10.0.1//org.keycloak.services.resources.LoginActionsService.processAuthentication(LoginActionsService.java:282)
	at org.keycloak.keycloak-services@10.0.1//org.keycloak.services.resources.LoginActionsService.authenticate(LoginActionsService.java:266)
	at org.keycloak.keycloak-services@10.0.1//org.keycloak.services.resources.LoginActionsService.authenticateForm(LoginActionsService.java:339)
	at jdk.internal.reflect.GeneratedMethodAccessor876.invoke(Unknown Source)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)

13:30:36,767 WARN  [org.keycloak.events] (default task-17) type=LOGIN_ERROR, realmId=OTPRealm, clientId=account, userId=null, ipAddress=172.17.0.1, error=invalid_user_credentials, auth_method=openid-connect, auth_type=code, redirect_uri=http://0.0.0.0:8087/auth/realms/OTPRealm/account/login-redirect, code_id=6e5a0ae8-6e63-480f-bb11-2ede8d5d1d48, username=username@user.name, authSessionParentId=6e5a0ae8-6e63-480f-bb11-2ede8d5d1d48, authSessionTabId=sYEQPf288Ow

Should there be any changes in the setup for the latest or is this a bug?

@thomasdarimont
Copy link
Author

Note that this code here is outdated. The ConditionaOtpAuthenticator was adopted a while ago by Keycloak and got adapted over time.

@atulmahind
Copy link

I am not using the code. In 4.5.0.FINAL the 2FA works just by following the set up steps but not in 10.0.1.

@atulmahind
Copy link

atulmahind commented May 22, 2020

Instead of Browser Dynamic Otp Forms use Browser Dynamic Otp Browser - Conditional OTP to add the execution. May be you can make this small update.

Your gist is the only place where I found this configuration. Cheers! :)

@rnfranco
Copy link

rnfranco commented Jun 9, 2020

Hi!
Thanks a lot for your work developing the ConditionalOtpFormAuthenticator! This is exactly what I was looking for!
I was also following the steps documented here and I couldn't make it work in 10.0.1.
I was wondering if there is other documentation about this that I could follow, or maybe I'm doing something wrong?
The other docs that I found are the javadocs and the Keycloak Server Administration.
Would upgrading to 10.0.2 fix this? (It's not mentioned in the release notes/Jira).

Thanks!

@pravat1974
Copy link

Hi
I want to OTP for few specific functions in my application. To start/finish the transaction(Function) keycloak should ask for OTP(Google Authenticator).If OTP is valid ,it should allow to complete the transaction.
Need help to implement this on keycloak 12.0.4

@karlkovaciny
Copy link

Note that this code here is outdated. The ConditionaOtpAuthenticator was adopted a while ago by Keycloak and got adapted over time.

Shouldn't this statement be at the top of the readme? "This code was adopted into Keycloak in version X and remains here only as an example of extending a service provider." Just to save everyone time wondering "does this repo do what I need?"

@Sasankasekh
Copy link

Hii,
When i click on send otp then i'm getting info 422 in console. Can someone help me

@myonaingwinn
Copy link

myonaingwinn commented Nov 15, 2023

More clear version 😄

https://access.redhat.com/solutions/6976632

Issue

  • When OTP is enabled from authentication(Browser Flow). It gets enabled for all the users in realm.
  • Any way to have OTP enabled for only some set of users (Conditional OTP)

Resolution

  • Follow below steps to enable conditional OTP for set of users
  • Create a new role for example "require_otp_role"
  • Go to Authentication -> Flows -> Select Browser.
  • Click on copy
  • Name the new flow browser browser_otp
  • Click on actions in the line Browse_otp Forms
  • Add execution: Conditional OTP Form.
  • Disable the OTP Form
  • Mark the Conditional OTP Form as required.
  • Click on Actions -> configure for the Conditional OTP Form
  • Give it the alias require_otp_flow
  • Select the require_otp_role from the Force OTP for Role
  • Configure the Fallback OTP handling to skip
  • mark "Condition - User Configured" as "DISABLED" from the "Browser_otp Browser - Conditional OTP" execution
  • assign the role created "require_otp_role" to the user which you want to have the OTP option available.

Root Cause

  • Conditional OTP's are not enabled by default. You can enable as described in Resolution section.

Diagnostic Steps
Browser flow:

  • Browser - Conditional OTP

Condition - User Role = 'REQUIRED'

  • Config: Alias=[alias-name], user role: [select client role to users]

@JoKrefting
Copy link

At least the above provided solutions showed for me with latest keycloak versions (26.5.0 - but i believe this always was the case) the problem that the resolution of @myonaingwinn has the disadvantage that users who voluntarily setup otp are not requested for otp any longer once i disable the OTP form.
But enabling OTP form and setting it to previous default value actually of ALTERNATIVE triggers during authentication flow the OTP execution twice which is first not communicated to a user doing the login and once entering the same code a second time u'll receive the error that the code already was used. So users are required to wait until the 30 seconds have past and then it actually fulfills the second step of authentication flow and user get's signed in.

I tried out many things but finally got it working to:

  1. allow users that have otp configured already (regardless if enforced or not) are requested for otp
  2. users not having entered an otp yet are checked if the have to setup otp due to enforced role constraint

This actually looks like:
grafik

with configuration for conditional credential config like:
grafik

I even automated it for easier adaption to multiple customers with the keycloak terraform provider and despite managing flows is quite fragile and error prone due to changing ID's once renaming a flow or changing priority i added some additional comments in the file as well.
So maybe this helps someone else as well:

###############################################
# Keycloak OTP Authentication Flow with Role Enforcement
# This configuration sets up a custom authentication flow in Keycloak
# that requires users assigned to a specific role to authenticate using
# OTP (One-Time Password). The flow is based on the default Browser flow,
# with modifications to include conditional OTP enforcement.
# BEWARE: Changing configuration here has serious impact on users AND confuses tofu a LOT and destroys resources randomly repeatedly without reason.
# Only workaround is to rename all resources while keeping the old flow name to avoid keycloak 500 errors.
# hence rename the local variable version whenever changing anything here.
###############################################

locals {
  version = "v3"
}

resource "keycloak_role" "require_otp_auth" {
  name        = "require_otp_auth"
  realm_id    = keycloak_realm.realm.id
  description = "Role to enforce OTP authentication for users or groups assigned to this role via Browser Login Flow."
}

resource "keycloak_authentication_flow" "browser_otp_v3" {
  realm_id    = keycloak_realm.realm.id
  description = "browser dynamic otp flow for everyone with role require_otp_auth"
  alias       = "browser_otp_${local.version}"
}

# old flow kept as otherwise keycloak error with still bound flow pops up
# TODO delete after migration on all realms is done
resource "keycloak_authentication_flow" "browser_otp_v2" {
  realm_id    = keycloak_realm.realm.id
  description = "browser dynamic otp flow for everyone with role require_otp_auth"
  alias       = "browser_otp_v2"
}
resource "keycloak_authentication_flow" "browser_otp" {
  realm_id    = keycloak_realm.realm.id
  description = "browser dynamic otp flow for everyone with role require_otp_auth"
  alias       = "browser_otp"
}


# first execution, rebuild from Browser Flow
resource "keycloak_authentication_execution" "execution_one" {
  realm_id          = keycloak_realm.realm.id
  parent_flow_alias = keycloak_authentication_flow.browser_otp_v3.alias
  authenticator     = "auth-cookie"
  requirement       = "ALTERNATIVE"
  priority          = 10
}

# second execution, rebuild from Browser Flow
resource "keycloak_authentication_execution" "execution_two" {
  realm_id          = keycloak_realm.realm.id
  parent_flow_alias = keycloak_authentication_flow.browser_otp_v3.alias
  authenticator     = "auth-spnego" # Kerberos
  requirement       = "DISABLED"
  priority          = 20
}

# third execution, rebuild from Browser Flow
resource "keycloak_authentication_execution" "execution_three" {
  realm_id          = keycloak_realm.realm.id
  parent_flow_alias = keycloak_authentication_flow.browser_otp_v3.alias
  authenticator     = "identity-provider-redirector"
  requirement       = "ALTERNATIVE"
  priority          = 30
}


resource "keycloak_authentication_subflow" "form" {
  realm_id          = keycloak_realm.realm.id
  alias             = "otp-form-alias_${local.version}"
  description       = "Username, password, otp and other auth forms."
  parent_flow_alias = keycloak_authentication_flow.browser_otp_v3.alias
  provider_id       = "basic-flow"
  requirement       = "ALTERNATIVE"
  priority          = 40
}

# inside subflow form, first execution: username-password form
resource "keycloak_authentication_execution" "form_execution_one" {
  realm_id          = keycloak_realm.realm.id
  parent_flow_alias = keycloak_authentication_subflow.form.alias
  authenticator     = "auth-username-password-form"
  requirement       = "REQUIRED"
  priority          = 10
}

# inside subflow form, second execution: conditional otp form flow
resource "keycloak_authentication_subflow" "form_flow_nested" {
  realm_id          = keycloak_realm.realm.id
  description       = "Flow to determine if the OTP is required for the authentication"
  alias             = "otp-form-alias-browser-conditional-otp_${local.version}"
  parent_flow_alias = keycloak_authentication_subflow.form.alias
  authenticator     = "auth-conditional-otp-form"
  requirement       = "CONDITIONAL"
  priority          = 20
}

# inside nested subflow form, first condition user configured for otp

# inside subflow form, first execution: username-password form
resource "keycloak_authentication_execution" "conditional_user_configured" {
  realm_id          = keycloak_realm.realm.id
  parent_flow_alias = keycloak_authentication_subflow.form_flow_nested.alias
  authenticator     = "conditional-user-configured"
  requirement       = "REQUIRED"
  priority          = 10
}

# inside nested subflow form, second execution: otp form
resource "keycloak_authentication_execution" "sub_form_otp" {
  realm_id          = keycloak_realm.realm.id
  parent_flow_alias = keycloak_authentication_subflow.form_flow_nested.alias
  authenticator     = "auth-otp-form"
  requirement       = "ALTERNATIVE"
  priority          = 20
}

# inside subflow form, second execution: conditional otp auth check
resource "keycloak_authentication_subflow" "require_otp_auth_check" {
  realm_id          = keycloak_realm.realm.id
  description       = "Flow to determine if the OTP is to be enforced for the authentication, only if not already provided before."
  alias             = "otp-required-flow-for-role_${local.version}"
  parent_flow_alias = keycloak_authentication_subflow.form.alias
  authenticator     = "auth-conditional-otp-form"
  requirement       = "CONDITIONAL"
  priority          = 30
}

# inside subflow form for otp required users, first execution: Credential OTP check to disable a second otp prompt if already provided by previous flow
resource "keycloak_authentication_execution" "conditional_credential_otp_check" {
  realm_id          = keycloak_realm.realm.id
  parent_flow_alias = keycloak_authentication_subflow.require_otp_auth_check.alias
  authenticator     = "conditional-credential"
  requirement       = "REQUIRED"
  priority          = 10
}

# configure conditional credential for existing otp to skip further otp prompt
resource "keycloak_authentication_execution_config" "check_otp_already_provided" {
  realm_id     = keycloak_realm.realm.id
  execution_id = keycloak_authentication_execution.conditional_credential_otp_check.id
  alias        = "otp-already-provided-check_${local.version}"
  config = {
    credentials = "otp",
    included    = "false"
  }
}

# inside subflow form for otp required users, second execution: conditional otp form to enforce otp only for users with role
resource "keycloak_authentication_execution" "conditional_otp_form" {
  realm_id          = keycloak_realm.realm.id
  parent_flow_alias = keycloak_authentication_subflow.require_otp_auth_check.alias
  authenticator     = "auth-conditional-otp-form"
  requirement       = "REQUIRED"
  priority          = 20
}

# configure conditional otp form to require otp only for users with specified role
resource "keycloak_authentication_execution_config" "require_otp_flow_for_role" {
  realm_id     = keycloak_realm.realm.id
  execution_id = keycloak_authentication_execution.conditional_otp_form.id
  alias        = "require_otp_flow_for_role_${local.version}"
  config = {
    forceOtpRole      = keycloak_role.require_otp_auth.name,
    defaultOtpOutcome = "skip"
  }
}

resource "keycloak_authentication_bindings" "browser_authentication_binding" {
  realm_id     = keycloak_realm.realm.id
  browser_flow = keycloak_authentication_flow.browser_otp_v3.alias
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment