diff --git a/docs/documentation/release_notes/topics/26_1_0.adoc b/docs/documentation/release_notes/topics/26_1_0.adoc index b0e0dbe6b6d9..623afce4352e 100644 --- a/docs/documentation/release_notes/topics/26_1_0.adoc +++ b/docs/documentation/release_notes/topics/26_1_0.adoc @@ -51,6 +51,10 @@ by the LDAP provider. As OpenShift v3 reached end-of-life a while back, support for identity brokering with OpenShift v3 has been removed from Keycloak. += New conditional authenticator `Condition - sub-flow executed` + +The `Condition - sub-flow executed` is a new conditional authenticator in {project_name}. The condition checks if a previous sub-flow was executed (or not executed) successfully during the authentication flow execution. For more details, see link:{adminguide_link}#conditions-in-conditional-flows[Conditions in conditional flows]. + = Defining dependencies between provider factories When developing extensions for {project_name}, developers can now specify dependencies between provider factories classes by implementing the method `dependsOn()` in the `ProviderFactory` interface. diff --git a/docs/documentation/server_admin/images/2fa-example1.png b/docs/documentation/server_admin/images/2fa-example1.png new file mode 100644 index 000000000000..6da90fb4fea8 Binary files /dev/null and b/docs/documentation/server_admin/images/2fa-example1.png differ diff --git a/docs/documentation/server_admin/images/2fa-example2-config.png b/docs/documentation/server_admin/images/2fa-example2-config.png new file mode 100644 index 000000000000..183c938dd17a Binary files /dev/null and b/docs/documentation/server_admin/images/2fa-example2-config.png differ diff --git a/docs/documentation/server_admin/images/2fa-example2.png b/docs/documentation/server_admin/images/2fa-example2.png new file mode 100644 index 000000000000..cc94b424e2a8 Binary files /dev/null and b/docs/documentation/server_admin/images/2fa-example2.png differ diff --git a/docs/documentation/server_admin/images/2fa-example3.png b/docs/documentation/server_admin/images/2fa-example3.png new file mode 100644 index 000000000000..2b17af38bf31 Binary files /dev/null and b/docs/documentation/server_admin/images/2fa-example3.png differ diff --git a/docs/documentation/server_admin/topics/authentication/conditions.adoc b/docs/documentation/server_admin/topics/authentication/conditions.adoc index 7020a20771a1..93cad7201286 100644 --- a/docs/documentation/server_admin/topics/authentication/conditions.adoc +++ b/docs/documentation/server_admin/topics/authentication/conditions.adoc @@ -45,6 +45,16 @@ Negate output::: You can negate the output. In other words, the attribute should not be present. +`Condition - sub-flow executed`:: +The condition checks if a previous sub-flow was successfully executed (or not executed) in the authentication process. Therefore, the flow can trigger other steps based on a previous sub-flow termination. These configuration fields exist: + +Flow name::: +The sub-flow name to check if it was executed or not executed. Required. + +Check result::: +When the condition evaluates to true. If `executed` returns true when the configured sub-flow was executed with output success, false otherwise. If `not-executed` returns false when the sub-flow was executed with output success, true otherwise (the negation of the previous option). + + ==== Explicitly deny/allow access in conditional flows You can allow or deny access to resources in a conditional flow. @@ -85,4 +95,40 @@ The last thing is defining the property with an error message in the login theme [source] ---- deny-role1 = You do not have required role! ----- \ No newline at end of file +---- + +==== 2FA conditional workflow examples + +The section presents some examples of conditional workflows that integrates 2nd Factor Authentication (2FA) in different ways. The examples copy the default `browser` flow and modify the configuration inside the `forms` sub-flow. + +===== Conditional 2FA sub-flow + +The default `browser` flow uses a `Conditional OTP` sub-flow that already gives a 2FA with OTP Form (One Time Password). Following the same idea, different 2FA methods can be integrated with the `Condition - User Configured`. + +.2FA all alternative +image:images/2fa-example1.png[2FA all alternative] + +The `forms` sub-flow contains another `2FA` conditional sub-flow with `Condition - user configured`. Three 2FA steps (OTP, Webauthn and Recovery Codes) are allowed as alternative steps. The user will be able to choose one of the three options, if they are configured for the user. As the sub-flow is conditional, the authentication process will complete successfully if no 2FA credential is configured. + +===== Conditional 2FA sub-flow and deny access + +The second example continues the previous one. After the `2FA` sub-flow, another flow `Deny access if no 2FA` is used to check if the previous `2FA` was not executed. In that case (the user has no 2FA credential configured) the access is denied. + +.2FA all alternative and deny access +image:images/2fa-example2.png[2FA all alternative and deny access] + +The `Condition - sub-flow executed` is configured to detect if the `2FA` sub-flow was not executed previously. + +.Configuration for the sub-flow executed +image:images/2fa-example2-config.png[Configuration for the sub-flow executed] + +The step `Deny access` denies the authentication if not executed. + +===== Conditional 2FA sub-flow with OTP default + +The last example is very similar to the previous one. Instead of denying the access, step `OTP Form` is configured as required. + +.2FA all alternative with OTP default +image:images/2fa-example3.png[2FA all alternative with OTP default] + +With this flow, if the user has none of the 2FA methods configured, the OTP setup will be enforced to continue the login. \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalSubFlowExecutedAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalSubFlowExecutedAuthenticator.java new file mode 100644 index 000000000000..43514d5d8b67 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalSubFlowExecutedAuthenticator.java @@ -0,0 +1,113 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.authentication.authenticators.conditional; + +import java.util.stream.Stream; +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticationFlowModel; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.sessions.AuthenticationSessionModel; + +/** + *

Conditional authenticator to know if a sub-flow was executed successfully in the authentication flow.

+ * + * @author rmartinc + */ +public class ConditionalSubFlowExecutedAuthenticator implements ConditionalAuthenticator { + + protected static final ConditionalSubFlowExecutedAuthenticator SINGLETON = new ConditionalSubFlowExecutedAuthenticator(); + private static final Logger logger = Logger.getLogger(ConditionalSubFlowExecutedAuthenticator.class); + + @Override + public boolean matchCondition(AuthenticationFlowContext context) { + final AuthenticatorConfigModel configModel = context.getAuthenticatorConfig(); + if (configModel == null || configModel.getConfig() == null) { + logger.warnf("No configuration defined for the conditional flow executed. Nothing executed."); + return false; + } + + final String flowAlias = configModel.getConfig().get(ConditionalSubFlowExecutedAuthenticatorFactory.FLOW_TO_CHECK); + final boolean executed = !ConditionalSubFlowExecutedAuthenticatorFactory.CHECK_RESULT_NOT_EXECUTED.equals( + configModel.getConfig().get(ConditionalSubFlowExecutedAuthenticatorFactory.CHECK_RESULT)); + if (flowAlias == null) { + logger.warnf("No flow configured in the option '%s'. Nothing executed.", ConditionalSubFlowExecutedAuthenticatorFactory.FLOW_TO_CHECK); + return !executed; + } + + final RealmModel realm = context.getRealm(); + final AuthenticationFlowModel flow = realm.getFlowByAlias(flowAlias); + if (flow == null) { + logger.warnf("No flow '%s' defined in the realm. Nothing executed.", flowAlias); + return !executed; + } + + final AuthenticationExecutionModel exec = locateExecutionFlowToCheck(realm, context.getTopLevelFlow().getId(), flow.getId()); + if (exec == null) { + logger.warnf("Cannot locate execution for flow '%s' in the top level flow '%s'. Nothing executed.", flowAlias, context.getTopLevelFlow().getAlias()); + return !executed; + } + + AuthenticationSessionModel.ExecutionStatus status = context.getAuthenticationSession().getExecutionStatus().get(exec.getId()); + logger.tracef("The authentication status for the flow '%s' is %s", flowAlias, status); + return executed + ? AuthenticationSessionModel.ExecutionStatus.SUCCESS.equals(status) + : !AuthenticationSessionModel.ExecutionStatus.SUCCESS.equals(status); + } + + @Override + public void action(AuthenticationFlowContext context) { + // no-op + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + // no-op + } + + @Override + public void close() { + // no-op + } + + private Stream flattened(RealmModel realm, AuthenticationExecutionModel flowExec) { + // flatten the execution model recursively only for flows + return Stream.concat(Stream.of(flowExec), + realm.getAuthenticationExecutionsStream(flowExec.getFlowId()) + .filter(AuthenticationExecutionModel::isAuthenticatorFlow) + .flatMap(exec -> flattened(realm, exec))); + } + + private AuthenticationExecutionModel locateExecutionFlowToCheck(RealmModel realm, String topFlowId, String flowId) { + return realm.getAuthenticationExecutionsStream(topFlowId) + .filter(AuthenticationExecutionModel::isAuthenticatorFlow) + .flatMap(exec -> flattened(realm, exec)) + .filter(exec -> flowId.equals(exec.getFlowId())) + .findAny() + .orElse(null); + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalSubFlowExecutedAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalSubFlowExecutedAuthenticatorFactory.java new file mode 100644 index 000000000000..eeb744dd85fe --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalSubFlowExecutedAuthenticatorFactory.java @@ -0,0 +1,118 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.authentication.authenticators.conditional; + +import java.util.List; +import org.keycloak.Config.Scope; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; + +/** + *

Conditional factory to know if a sub-flow was executed successfully in the authentication flow.

+ * + * @author rmartinc + */ +public class ConditionalSubFlowExecutedAuthenticatorFactory implements ConditionalAuthenticatorFactory { + + public static final String PROVIDER_ID = "conditional-sub-flow-executed"; + public static final String FLOW_TO_CHECK = "flow_to_check"; + public static final String CHECK_RESULT = "check_result"; + public static final String CHECK_RESULT_EXECUTED = "executed"; + public static final String CHECK_RESULT_NOT_EXECUTED = "not-executed"; + + @Override + public void init(Scope config) { + // no-op + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + // no-op + } + + @Override + public void close() { + // no-op + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Condition - sub-flow executed"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public Requirement[] getRequirementChoices() { + return new Requirement[]{AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.DISABLED}; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Condition to evaluate if a sub-flow was executed successfully during the authentication process"; + } + + @Override + public List getConfigProperties() { + return ProviderConfigurationBuilder.create() + .property() + .name(FLOW_TO_CHECK) + .type(ProviderConfigProperty.STRING_TYPE) + .label("Flow name") + .helpText("The sub-flow name to check if it was executed.") + .required(true) + .add() + .property() + .name(CHECK_RESULT) + .type(ProviderConfigProperty.LIST_TYPE) + .label("Check result") + .helpText( + """ + When the condition evaluates to true. + If 'executed' returns true when the configured sub-flow was executed with output success, false otherwise. + If 'not-executed' returns false when the sub-flow was executed with output success, true otherwise. + """ + ) + .required(true) + .options(List.of(CHECK_RESULT_EXECUTED, CHECK_RESULT_NOT_EXECUTED)) + .defaultValue(CHECK_RESULT_EXECUTED) + .add() + .build(); + } + + @Override + public ConditionalAuthenticator getSingleton() { + return ConditionalSubFlowExecutedAuthenticator.SINGLETON; + } +} diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java b/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java index 2f0ff50c6d33..1300f2af6ef3 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java @@ -413,8 +413,7 @@ public Response copy(@Parameter(description="name of the existing authentication data.put("id", copy.getId()); adminEvent.operation(OperationType.CREATE).resourcePath(session.getContext().getUri()).representation(data).success(); - return Response.status(Response.Status.CREATED).build(); - + return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(copy.getId()).build()).build(); } public static AuthenticationFlowModel copyFlow(KeycloakSession session, RealmModel realm, AuthenticationFlowModel flow, String newName) { diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory index 8a15cee7dc60..58e04570281c 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -22,6 +22,7 @@ org.keycloak.authentication.authenticators.browser.PasswordFormFactory org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory org.keycloak.authentication.authenticators.browser.SpnegoAuthenticatorFactory org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticatorFactory +org.keycloak.authentication.authenticators.conditional.ConditionalSubFlowExecutedAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalRoleAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory @@ -54,4 +55,4 @@ org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactor org.keycloak.authentication.authenticators.sessionlimits.UserSessionLimitsAuthenticatorFactory org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory -org.keycloak.authentication.authenticators.browser.PasskeysConditionalUIAuthenticatorFactory \ No newline at end of file +org.keycloak.authentication.authenticators.browser.PasskeysConditionalUIAuthenticatorFactory diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java index 37895fa71ee2..861914fe8c9b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java @@ -234,6 +234,7 @@ private List> expectedAuthProviders() { addProviderInfo(result, "idp-add-organization-member", "Organization Member Onboard", "Adds a federated user as a member of an organization"); addProviderInfo(result, "organization", "Organization Identity-First Login", "If organizations are enabled, automatically redirects users to the corresponding identity provider."); + addProviderInfo(result, "conditional-sub-flow-executed", "Condition - sub-flow executed", "Condition to evaluate if a sub-flow was executed successfully during the authentication process"); return result; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/login/ConditionalSubFlowExecutedAuthenticatorTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/login/ConditionalSubFlowExecutedAuthenticatorTest.java new file mode 100644 index 000000000000..212c3894306a --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/login/ConditionalSubFlowExecutedAuthenticatorTest.java @@ -0,0 +1,227 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.login; + +import java.util.Map; +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.resource.AuthenticationManagementResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.authentication.authenticators.access.DenyAccessAuthenticatorFactory; +import org.keycloak.authentication.authenticators.conditional.ConditionalSubFlowExecutedAuthenticatorFactory; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.utils.TimeBasedOTP; +import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation; +import org.keycloak.representations.idm.AuthenticatorConfigRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.auth.page.login.OneTimeCode; +import org.keycloak.testsuite.pages.ErrorPage; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.pages.LoginTotpPage; +import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse; + +/** + *

Test for the ConditionalSubFlowExecutedAuthenticator. A test parent + * flow is created to substitute the original browser flow. This flow + * adds inside the forms sub-flow the condition sub-flow executed defined + * over the conditional OTP step. This way tests check if the OTP step was + * executed or not. The sub-flow adds a deny step for the condition.

+ * + * @author rmartinc + */ +public class ConditionalSubFlowExecutedAuthenticatorTest extends AbstractTestRealmKeycloakTest { + + @Page + protected LoginPage loginPage; + + @Page + protected ErrorPage errorPage; + + @Page + protected LoginTotpPage loginTotpPage; + + @Page + protected OneTimeCode oneTimeCodePage; + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + // no-op + } + + @Test + public void testWithoutOtpConfiguredExecuted() { + configureConditionalSubFlowExecutedAuthenticatorInFlow("test Browser - Conditional OTP", ConditionalSubFlowExecutedAuthenticatorFactory.CHECK_RESULT_EXECUTED); + + loginPage.open(); + + loginPage.assertCurrent(); + loginPage.login("test-user@localhost", "password"); + + // no otp => check executed => allowed + checkAllowed("test-user@localhost"); + } + + @Test + public void testWithoutOtpConfiguredNotExecuted() { + configureConditionalSubFlowExecutedAuthenticatorInFlow("test Browser - Conditional OTP", ConditionalSubFlowExecutedAuthenticatorFactory.CHECK_RESULT_NOT_EXECUTED); + + loginPage.open(); + + loginPage.assertCurrent(); + loginPage.login("test-user@localhost", "password"); + + // no otp => check not-executed => denied + checkDenied(); + } + + @Test + public void testWithOtpConfiguredExecuted() { + configureConditionalSubFlowExecutedAuthenticatorInFlow("test Browser - Conditional OTP", ConditionalSubFlowExecutedAuthenticatorFactory.CHECK_RESULT_EXECUTED); + + loginPage.open(); + + loginPage.assertCurrent(); + loginPage.login("user-with-one-configured-otp", "password"); + + loginTotpPage.assertCurrent(); + oneTimeCodePage.sendCode(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")); + + // otp => check executed => denied + checkDenied(); + } + + @Test + public void testWithOtpConfiguredNotExecuted() { + configureConditionalSubFlowExecutedAuthenticatorInFlow("test Browser - Conditional OTP", ConditionalSubFlowExecutedAuthenticatorFactory.CHECK_RESULT_NOT_EXECUTED); + + loginPage.open(); + + loginPage.assertCurrent(); + loginPage.login("user-with-two-configured-otp", "password"); + + loginTotpPage.assertCurrent(); + oneTimeCodePage.sendCode(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")); + + // otp => check not-executed => allowed + checkAllowed("user-with-two-configured-otp"); + } + + @Test + public void testWithInvalidFlowExecuted() { + configureConditionalSubFlowExecutedAuthenticatorInFlow("invalid flow", ConditionalSubFlowExecutedAuthenticatorFactory.CHECK_RESULT_EXECUTED); + + loginPage.open(); + + loginPage.assertCurrent(); + loginPage.login("test-user@localhost", "password"); + + // no flow => check executed => allowed + checkAllowed("test-user@localhost"); + } + + @Test + public void testWithInvalidFlowNotExecuted() { + configureConditionalSubFlowExecutedAuthenticatorInFlow("invalid flow", ConditionalSubFlowExecutedAuthenticatorFactory.CHECK_RESULT_NOT_EXECUTED); + + loginPage.open(); + + loginPage.assertCurrent(); + loginPage.login("test-user@localhost", "password"); + + // no flow => check executed => denied + checkDenied(); + } + + private void checkDenied() { + errorPage.assertCurrent(); + Assert.assertEquals("Access denied", errorPage.getError()); + + events.expect(EventType.LOGIN_ERROR).user((String) null).error(Errors.ACCESS_DENIED).assertEvent(); + } + + private void checkAllowed(String username) { + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + Assert.assertNotNull(code); + AccessTokenResponse res = oauth.doAccessTokenRequest(code, "password"); + Assert.assertNull(res.getError()); + Assert.assertNotNull(res.getAccessToken()); + + events.expectLogin().user(AssertEvents.isUUID()).detail(Details.USERNAME, username).assertEvent(); + } + + private void configureConditionalSubFlowExecutedAuthenticatorInFlow(String flowName, String check) { + // clone the browser flow and add another conditional flow that checks + // if the OTP flow was executed or not executed to deny the access + + RealmResource realmRes = testRealm(); + AuthenticationManagementResource authRes = realmRes.flows(); + + // revert the flows if already changed + RealmRepresentation realmRep = realmRes.toRepresentation(); + if (!realmRep.getBrowserFlow().equals("browser")) { + realmRep.setBrowserFlow("browser"); + realmRes.update(realmRep); + authRes.deleteFlow(authRes.getFlows().stream().filter(f -> "test".equals(f.getAlias())).findAny().get().getId()); + } + + // copy the browser flow into a test one + authRes.copy("browser", Map.of("newName", "test")); + + // create a new flow to check if 2FA/OTP was executed or not set to conditional + authRes.addExecutionFlow("test forms", Map.of("alias", "2FA Executed", "provider", "registration-page-form", "type", "basic-flow")); + AuthenticationExecutionInfoRepresentation testFormExec = authRes.getExecutions("test forms").stream() + .filter(e -> e.getFlowId() != null && AuthenticationExecutionModel.Requirement.DISABLED.name().equals(e.getRequirement())) + .findAny().get(); + testFormExec.setRequirement(AuthenticationExecutionModel.Requirement.CONDITIONAL.name()); + authRes.updateExecutions("test forms", testFormExec); + + // create the condition for sub-flow executed as required + authRes.addExecution("2FA Executed", Map.of("provider", ConditionalSubFlowExecutedAuthenticatorFactory.PROVIDER_ID)); + AuthenticationExecutionInfoRepresentation conditionExec = authRes.getExecutions("2FA Executed").stream() + .filter(e -> ConditionalSubFlowExecutedAuthenticatorFactory.PROVIDER_ID.equals(e.getProviderId())).findAny().orElse(null); + conditionExec.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED.name()); + authRes.updateExecutions("2FA Executed", conditionExec); + + // create the config for the condition + AuthenticatorConfigRepresentation config = new AuthenticatorConfigRepresentation(); + config.setAlias("config"); + config.setConfig(Map.of(ConditionalSubFlowExecutedAuthenticatorFactory.FLOW_TO_CHECK, flowName, ConditionalSubFlowExecutedAuthenticatorFactory.CHECK_RESULT, check)); + authRes.newExecutionConfig(conditionExec.getId(), config); + + // add the deny access as required if condition evaluates to true + authRes.addExecution("2FA Executed", Map.of("provider", DenyAccessAuthenticatorFactory.PROVIDER_ID)); + AuthenticationExecutionInfoRepresentation denyExec = authRes.getExecutions("2FA Executed").stream() + .filter(e -> DenyAccessAuthenticatorFactory.PROVIDER_ID.equals(e.getProviderId())).findAny().orElse(null); + denyExec.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED.name()); + authRes.updateExecutions("2FA Executed", denyExec); + + // assign the new flow to the browser binding + realmRep.setBrowserFlow("test"); + realmRes.update(realmRep); + } +}