diff --git a/docs/documentation/release_notes/topics/26_1_0.adoc b/docs/documentation/release_notes/topics/26_1_0.adoc index 0e119ca4e6a..468bd485146 100644 --- a/docs/documentation/release_notes/topics/26_1_0.adoc +++ b/docs/documentation/release_notes/topics/26_1_0.adoc @@ -56,9 +56,11 @@ 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` += New conditional authenticators `Condition - sub-flow executed` and `Condition - client scope` -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]. +The `Condition - sub-flow executed` and `Condition - client scope` are new conditional authenticators in {project_name}. The condition `Condition - sub-flow executed` checks if a previous sub-flow was +executed (or not executed) successfully during the authentication flow execution. The condition `Condition - client scope` checks if a configured client scope is present as a client scope of the +client requesting authentication. For more details, see link:{adminguide_link}#conditions-in-conditional-flows[Conditions in conditional flows]. = Defining dependencies between provider factories diff --git a/docs/documentation/server_admin/images/post-login-flow-client-scope-config.png b/docs/documentation/server_admin/images/post-login-flow-client-scope-config.png new file mode 100644 index 00000000000..8be6150b344 Binary files /dev/null and b/docs/documentation/server_admin/images/post-login-flow-client-scope-config.png differ diff --git a/docs/documentation/server_admin/images/post-login-flow-client-scope.png b/docs/documentation/server_admin/images/post-login-flow-client-scope.png new file mode 100644 index 00000000000..68c0d3278c7 Binary files /dev/null and b/docs/documentation/server_admin/images/post-login-flow-client-scope.png differ diff --git a/docs/documentation/server_admin/images/post-login-flow-otp.png b/docs/documentation/server_admin/images/post-login-flow-otp.png new file mode 100644 index 00000000000..df452c4c98d Binary files /dev/null and b/docs/documentation/server_admin/images/post-login-flow-otp.png differ diff --git a/docs/documentation/server_admin/topics.adoc b/docs/documentation/server_admin/topics.adoc index b4e1f9ad82b..6909103e754 100644 --- a/docs/documentation/server_admin/topics.adoc +++ b/docs/documentation/server_admin/topics.adoc @@ -50,6 +50,7 @@ include::topics/identity-broker/suggested.adoc[] include::topics/identity-broker/mappers.adoc[] include::topics/identity-broker/session-data.adoc[] include::topics/identity-broker/first-login-flow.adoc[] +include::topics/identity-broker/post-login-flow.adoc[] include::topics/identity-broker/tokens.adoc[] include::topics/identity-broker/logout.adoc[] include::topics/sso-protocols.adoc[] diff --git a/docs/documentation/server_admin/topics/authentication/conditions.adoc b/docs/documentation/server_admin/topics/authentication/conditions.adoc index 93cad720128..83da5b1e304 100644 --- a/docs/documentation/server_admin/topics/authentication/conditions.adoc +++ b/docs/documentation/server_admin/topics/authentication/conditions.adoc @@ -54,6 +54,15 @@ 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). +`Condition - client scope`:: +The condition to evaluate if a configured client scope is present as a client scope of the client requesting authentication. These configuration fields exist: + +Client scope name::: +The name of the client scope, which should be present as a client scope of the client, which is requesting authentication. If requested client scope is default client scope of the client requesting login, the condition will be evaluated to true. If requested client scope is optional client scope of the client requesting login, condition will be evaluated to true if client scope is sent by the client in the login request (for example, by the `scope` parameter in case of OIDC/OAuth2 client login). Required. + +Negate output::: +Apply a NOT to the check result. When this is true, then the condition will evaluate to true just if configured client scope is not present. + ==== Explicitly deny/allow access in conditional flows @@ -124,6 +133,7 @@ image:images/2fa-example2-config.png[Configuration for the sub-flow executed] The step `Deny access` denies the authentication if not executed. +[[_conditional-2fa-otp-default]] ===== 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. @@ -131,4 +141,4 @@ The last example is very similar to the previous one. Instead of denying the acc .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 +With this flow, if the user has none of the 2FA methods configured, the OTP setup will be enforced to continue the login. diff --git a/docs/documentation/server_admin/topics/identity-broker/default-provider.adoc b/docs/documentation/server_admin/topics/identity-broker/default-provider.adoc index 53f5e2359aa..652e938b987 100644 --- a/docs/documentation/server_admin/topics/identity-broker/default-provider.adoc +++ b/docs/documentation/server_admin/topics/identity-broker/default-provider.adoc @@ -14,3 +14,6 @@ If {project_name} does not find the configured default identity provider, the login form is displayed. This authenticator is responsible for processing the `kc_idp_hint` query parameter. See the <<_client_suggested_idp, client suggested identity provider>> section for more information. + +NOTE: The authenticator will redirect to the identity provider and authentication is delegated to the identity provider. The `browser` authentication flow will not continue after the login with the identity provider +is successfully finished. If you want to perform additional steps after the identity provider login (for example 2-factor authentication), it may be needed to configure <<_identity_broker_post_login_flow, Post login flow>>. diff --git a/docs/documentation/server_admin/topics/identity-broker/post-login-flow.adoc b/docs/documentation/server_admin/topics/identity-broker/post-login-flow.adoc new file mode 100644 index 00000000000..b0a5ba3d6a4 --- /dev/null +++ b/docs/documentation/server_admin/topics/identity-broker/post-login-flow.adoc @@ -0,0 +1,44 @@ +[[_identity_broker_post_login_flow]] + +=== Post login flow + +Post login flow is useful for the situations when you want to trigger some additional authentication actions after every login with the particular identity provider. +For example, you may want to trigger 2-factor authentication after every login of {project_name} to `Facebook` because `Facebook` does not provide 2-factor authentication during its login. + +Once you setup the authentication flow with the needed steps, set it as `Post login flow` when configuring the identity provider. + +==== Post login flow examples + +===== Requesting 2-factor authentication after identity provider login + +The easiest way is to enforce authentication with one particular 2-factor method. For example, when requesting OTP, the flow can look like this with only a single authenticator configured. +This type of flow asks the user to configure the OTP during the first login with the identity provide when the user does not have OTP set on the account. + +.2FA post login flow with OTP +image:images/post-login-flow-otp.png[Post login OTP] + +The more complex setup can include multiple 2-factor authentication methods configured as `ALTERNATIVE`. In this case, make sure that the user is requested to setup one of +the methods if that user does not yet have any 2-factor authentication configured on the account. This could be done as follows: + +* Make sure that one of the 2-factor methods is configured as `REQUIRED` in the <<_identity_broker_first_login, First login flow>>. This method can works if you expect all your users to be registered by +the identity provider login. + +* Wrap the 2-factor methods as `ALTERNATIVE` into a conditional subflow such as one called `2FA` and create another conditional subflow such as one called `OTP if no 2FA`, +which will be triggered only if the previous subflow was not executed and will ask user to add one of the 2-factor methods (for example, OTP). The example of a similar flow configuration is provided +in the <<_conditional-2fa-otp-default, Conditions section of the Authentication flows chapter>>. + +==== Requesting additional authentication steps for the dedicated clients + +In some cases, a client or group of clients may need to perform some additional steps after identity provider login. +The following is an example of a flow that prescribes that when the client scope `foo` is requested, the user is required to authenticate with the OTP after identity provider login. + +.2FA post login flow with client scope and OTP +image:images/post-login-flow-client-scope.png[Post login with client scope and OTP] + +This is an example of configuring the `Condition - client scope` for requesting the specified client scope. + +.2FA post login flow client scope configuration +image:images/post-login-flow-client-scope-config.png[Post login flow client scope configuration] + +The requested clients need to have this client scope set on them either +as default or as optional. In the latter case, the flow is executed only if the client scope is requested by the client (for example, by the `scope` parameter in the case of OIDC/OAuth2 client logins). diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientScopeAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientScopeAuthenticator.java new file mode 100644 index 00000000000..a6b2697238a --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientScopeAuthenticator.java @@ -0,0 +1,93 @@ +/* + * 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 org.jboss.logging.Logger; +import org.keycloak.OAuth2Constants; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.sessions.AuthenticationSessionModel; + +/** + * Conditional authenticator to check if specified client-scope is present in the authentication request + * + * @author Marek Posolda + */ +public class ConditionalClientScopeAuthenticator implements ConditionalAuthenticator { + + protected static final ConditionalClientScopeAuthenticator SINGLETON = new ConditionalClientScopeAuthenticator(); + + private static final Logger logger = Logger.getLogger(ConditionalClientScopeAuthenticator.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 client scope authenticator."); + return false; + } + + final String clientScopeName = configModel.getConfig().get(ConditionalClientScopeAuthenticatorFactory.CLIENT_SCOPE); + boolean negateOutput = Boolean.parseBoolean(configModel.getConfig().get(ConditionalClientScopeAuthenticatorFactory.CONF_NEGATE)); + if (clientScopeName == null) { + logger.warnf("No client scope configured in the option '%s' of the configuration '%s'.", ConditionalClientScopeAuthenticatorFactory.CLIENT_SCOPE, configModel.getAlias()); + return false; + } + + final RealmModel realm = context.getRealm(); + ClientScopeModel targetClientScope = KeycloakModelUtils.getClientScopeByName(context.getRealm(), clientScopeName); + if (targetClientScope == null) { + logger.warnf("No client scope '%s' defined in the realm '%s'.", clientScopeName, realm.getName()); + return false; + } + + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + boolean clientScopePresent = TokenManager.getRequestedClientScopes(context.getSession(), authSession.getClientNote(OAuth2Constants.SCOPE), authSession.getClient(), authSession.getAuthenticatedUser()) + .anyMatch(clientScope -> targetClientScope.getId().equals(clientScope.getId())); + + return negateOutput != clientScopePresent; + } + + @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 + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientScopeAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientScopeAuthenticatorFactory.java new file mode 100644 index 00000000000..9256e743fdc --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientScopeAuthenticatorFactory.java @@ -0,0 +1,110 @@ +/* + * 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; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; + +/** + * @author Marek Posolda + */ +public class ConditionalClientScopeAuthenticatorFactory implements ConditionalAuthenticatorFactory { + + public static final String PROVIDER_ID = "conditional-client-scope"; + public static final String CLIENT_SCOPE = "client_scope"; + public static final String CONF_NEGATE = "negate"; + + @Override + public void init(Config.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 - client scope"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return new AuthenticationExecutionModel.Requirement[]{AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.DISABLED}; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Condition to evaluate if a configured client scope is present as a client scope of the client requesting authentication"; + } + + @Override + public List getConfigProperties() { + return ProviderConfigurationBuilder.create() + .property() + .name(CLIENT_SCOPE) + .type(ProviderConfigProperty.STRING_TYPE) + .label("Client scope name") + .helpText("The name of the client scope, which should be present as a client scope of the client, which is requesting authentication. If requested client scope is default client scope of the client requesting login, the condition will be evaluated to true. If requested client scope is optional client scope of the client requesting login, condition will be evaluated to true if client scope is sent by the client in the login request (EG. by the 'scope' parameter in case of OIDC/OAuth2 client login)") + .required(true) + .add() + .property() + .name(CONF_NEGATE) + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .label("Negate output") + .helpText( + "Apply a NOT to the check result. When this is true, then the condition will evaluate to true just if configured client scope is not present" + ) + .required(true) + .add() + .build(); + } + + @Override + public ConditionalAuthenticator getSingleton() { + return ConditionalClientScopeAuthenticator.SINGLETON; + } +} 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 58e04570281..96d97ec16de 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 @@ -23,6 +23,7 @@ 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.ConditionalClientScopeAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalRoleAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory 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 861914fe8c9..44a86e66794 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 @@ -235,6 +235,7 @@ public class ProvidersTest extends AbstractAuthenticationTest { 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"); + addProviderInfo(result, "conditional-client-scope", "Condition - client scope", "Condition to evaluate if a configured client scope is present as a client scope of the client requesting authentication"); return result; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTest.java index 20c71c87714..ffa2fc1d4d0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTest.java @@ -47,7 +47,6 @@ import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.broker.util.SimpleHttpDefault; -import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.util.AccountHelper; import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.WaitUtils; @@ -68,9 +67,6 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; -import static org.keycloak.models.utils.TimeBasedOTP.DEFAULT_INTERVAL_SECONDS; -import static org.keycloak.testsuite.admin.ApiUtil.removeUserByUsername; -import static org.keycloak.testsuite.broker.BrokerRunOnServerUtil.configurePostBrokerLoginWithOTP; import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS; import static org.keycloak.testsuite.broker.BrokerTestConstants.REALM_PROV_NAME; import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage; @@ -279,165 +275,6 @@ public final class KcOidcBrokerTest extends AbstractAdvancedBrokerTest { Assert.assertEquals("hard-coded", user.getAttributes().get("hard-coded").get(0)); } - /** - * Refers to in old test suite: PostBrokerFlowTest#testBrokerReauthentication_samlBrokerWithOTPRequired - */ - @Test - public void testReauthenticationSamlBrokerWithOTPRequired() throws Exception { - KcSamlBrokerConfiguration samlBrokerConfig = KcSamlBrokerConfiguration.INSTANCE; - ClientRepresentation samlClient = samlBrokerConfig.createProviderClients().get(0); - IdentityProviderRepresentation samlBroker = samlBrokerConfig.setUpIdentityProvider(); - RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName()); - - try { - updateExecutions(AbstractBrokerTest::disableUpdateProfileOnFirstLogin); - adminClient.realm(bc.providerRealmName()).clients().create(samlClient); - consumerRealm.identityProviders().create(samlBroker); - - oauth.clientId("broker-app"); - loginPage.open(bc.consumerRealmName()); - - testingClient.server(bc.consumerRealmName()).run(configurePostBrokerLoginWithOTP(samlBrokerConfig.getIDPAlias())); - logInWithBroker(samlBrokerConfig); - - totpPage.assertCurrent(); - String totpSecret = totpPage.getTotpSecret(); - totpPage.configure(totp.generateTOTP(totpSecret)); - - AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); - AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); - - setOtpTimeOffset(DEFAULT_INTERVAL_SECONDS, totp); - - oauth.clientId("broker-app"); - loginPage.open(bc.consumerRealmName()); - - logInWithBroker(bc); - - waitForPage(driver, "account already exists", false); - idpConfirmLinkPage.assertCurrent(); - idpConfirmLinkPage.clickLinkAccount(); - - loginPage.clickSocial(samlBrokerConfig.getIDPAlias()); - waitForPage(driver, "sign in to", true); - log.debug("Logging in"); - loginTotpPage.login(totp.generateTOTP(totpSecret)); - - assertNumFederatedIdentities(consumerRealm.users().search(samlBrokerConfig.getUserLogin()).get(0).getId(), 2); - } finally { - updateExecutions(AbstractBrokerTest::setUpMissingUpdateProfileOnFirstLogin); - removeUserByUsername(consumerRealm, "consumer"); - } - } - - /** - * Refers to in old test suite: PostBrokerFlowTest#testBrokerReauthentication_oidcBrokerWithOTPRequired - */ - @Test - public void testReauthenticationOIDCBrokerWithOTPRequired() throws Exception { - KcSamlBrokerConfiguration samlBrokerConfig = KcSamlBrokerConfiguration.INSTANCE; - ClientRepresentation samlClient = samlBrokerConfig.createProviderClients().get(0); - IdentityProviderRepresentation samlBroker = samlBrokerConfig.setUpIdentityProvider(); - RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName()); - - try { - updateExecutions(AbstractBrokerTest::disableUpdateProfileOnFirstLogin); - adminClient.realm(bc.providerRealmName()).clients().create(samlClient); - consumerRealm.identityProviders().create(samlBroker); - - oauth.clientId("broker-app"); - loginPage.open(bc.consumerRealmName()); - - logInWithBroker(samlBrokerConfig); - AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); - AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); - - testingClient.server(bc.consumerRealmName()).run(configurePostBrokerLoginWithOTP(bc.getIDPAlias())); - - oauth.clientId("broker-app"); - loginPage.open(bc.consumerRealmName()); - - logInWithBroker(bc); - - waitForPage(driver, "account already exists", false); - idpConfirmLinkPage.assertCurrent(); - idpConfirmLinkPage.clickLinkAccount(); - loginPage.clickSocial(samlBrokerConfig.getIDPAlias()); - - totpPage.assertCurrent(); - String totpSecret = totpPage.getTotpSecret(); - totpPage.configure(totp.generateTOTP(totpSecret)); - logoutFromRealm(getConsumerRoot(), bc.consumerRealmName()); - - assertNumFederatedIdentities(consumerRealm.users().search(samlBrokerConfig.getUserLogin()).get(0).getId(), 2); - } finally { - updateExecutions(AbstractBrokerTest::setUpMissingUpdateProfileOnFirstLogin); - removeUserByUsername(consumerRealm, "consumer"); - } - } - - /** - * Refers to in old test suite: PostBrokerFlowTest#testBrokerReauthentication_bothBrokerWithOTPRequired - */ - @Test - public void testReauthenticationBothBrokersWithOTPRequired() throws Exception { - final RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName()); - final RealmResource providerRealm = adminClient.realm(bc.providerRealmName()); - - try (RealmAttributeUpdater rauConsumer = new RealmAttributeUpdater(consumerRealm).setOtpPolicyCodeReusable(true).update(); - RealmAttributeUpdater rauProvider = new RealmAttributeUpdater(providerRealm).setOtpPolicyCodeReusable(true).update()) { - - KcSamlBrokerConfiguration samlBrokerConfig = KcSamlBrokerConfiguration.INSTANCE; - ClientRepresentation samlClient = samlBrokerConfig.createProviderClients().get(0); - IdentityProviderRepresentation samlBroker = samlBrokerConfig.setUpIdentityProvider(); - - try { - updateExecutions(AbstractBrokerTest::disableUpdateProfileOnFirstLogin); - providerRealm.clients().create(samlClient); - consumerRealm.identityProviders().create(samlBroker); - - oauth.clientId("broker-app"); - loginPage.open(bc.consumerRealmName()); - - testingClient.server(bc.consumerRealmName()).run(configurePostBrokerLoginWithOTP(samlBrokerConfig.getIDPAlias())); - logInWithBroker(samlBrokerConfig); - totpPage.assertCurrent(); - String totpSecret = totpPage.getTotpSecret(); - totpPage.configure(totp.generateTOTP(totpSecret)); - AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); - AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); - - testingClient.server(bc.consumerRealmName()).run(configurePostBrokerLoginWithOTP(bc.getIDPAlias())); - oauth.clientId("broker-app"); - loginPage.open(bc.consumerRealmName()); - - logInWithBroker(bc); - - waitForPage(driver, "account already exists", false); - idpConfirmLinkPage.assertCurrent(); - idpConfirmLinkPage.clickLinkAccount(); - loginPage.clickSocial(samlBrokerConfig.getIDPAlias()); - - loginTotpPage.assertCurrent(); - loginTotpPage.login(totp.generateTOTP(totpSecret)); - AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); - AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); - - oauth.clientId("broker-app"); - loginPage.open(bc.consumerRealmName()); - - logInWithBroker(bc); - - loginTotpPage.assertCurrent(); - loginTotpPage.login(totp.generateTOTP(totpSecret)); - - assertNumFederatedIdentities(consumerRealm.users().search(samlBrokerConfig.getUserLogin()).get(0).getId(), 2); - } finally { - updateExecutions(AbstractBrokerTest::setUpMissingUpdateProfileOnFirstLogin); - removeUserByUsername(consumerRealm, "consumer"); - } - } - } @Test public void testInvalidIssuedFor() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcPostBrokerLoginTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcPostBrokerLoginTest.java new file mode 100644 index 00000000000..14109a84d8c --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcPostBrokerLoginTest.java @@ -0,0 +1,314 @@ +/* + * 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.broker; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.authentication.AuthenticationFlow; +import org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory; +import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory; +import org.keycloak.authentication.authenticators.conditional.ConditionalClientScopeAuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticationFlowModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.utils.TimeBasedOTP; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.client.KeycloakTestingClient; +import org.keycloak.testsuite.updaters.RealmAttributeUpdater; +import org.keycloak.testsuite.util.AccountHelper; +import org.keycloak.testsuite.util.FlowUtil; +import static org.keycloak.models.utils.TimeBasedOTP.DEFAULT_INTERVAL_SECONDS; +import static org.keycloak.testsuite.admin.ApiUtil.removeUserByUsername; +import static org.keycloak.testsuite.broker.BrokerRunOnServerUtil.configurePostBrokerLoginWithOTP; +import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot; +import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage; + +/** + * @author Marek Posolda + */ +public class KcOidcPostBrokerLoginTest extends AbstractInitializedBaseBrokerTest { + + private static final KcOidcBrokerConfiguration BROKER_CONFIG_INSTANCE = new KcOidcBrokerConfiguration(); + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Override + protected BrokerConfiguration getBrokerConfiguration() { + return BROKER_CONFIG_INSTANCE; + } + + @Before + public void setUpTotp() { + totp = new TimeBasedOTP(); + } + + + @Test + public void testReauthenticationSamlBrokerWithOTPRequired() throws Exception { + KcSamlBrokerConfiguration samlBrokerConfig = KcSamlBrokerConfiguration.INSTANCE; + ClientRepresentation samlClient = samlBrokerConfig.createProviderClients().get(0); + IdentityProviderRepresentation samlBroker = samlBrokerConfig.setUpIdentityProvider(); + RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName()); + + try { + updateExecutions(AbstractBrokerTest::disableUpdateProfileOnFirstLogin); + adminClient.realm(bc.providerRealmName()).clients().create(samlClient); + consumerRealm.identityProviders().create(samlBroker); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + + testingClient.server(bc.consumerRealmName()).run(configurePostBrokerLoginWithOTP(samlBrokerConfig.getIDPAlias())); + logInWithBroker(samlBrokerConfig); + + totpPage.assertCurrent(); + String totpSecret = totpPage.getTotpSecret(); + totpPage.configure(totp.generateTOTP(totpSecret)); + + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); + + setOtpTimeOffset(DEFAULT_INTERVAL_SECONDS, totp); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + + logInWithBroker(bc); + + waitForPage(driver, "account already exists", false); + idpConfirmLinkPage.assertCurrent(); + idpConfirmLinkPage.clickLinkAccount(); + + loginPage.clickSocial(samlBrokerConfig.getIDPAlias()); + waitForPage(driver, "sign in to", true); + log.debug("Logging in"); + loginTotpPage.login(totp.generateTOTP(totpSecret)); + + assertNumFederatedIdentities(consumerRealm.users().search(samlBrokerConfig.getUserLogin()).get(0).getId(), 2); + } finally { + updateExecutions(AbstractBrokerTest::setUpMissingUpdateProfileOnFirstLogin); + removeUserByUsername(consumerRealm, "consumer"); + } + } + + + @Test + public void testReauthenticationOIDCBrokerWithOTPRequired() throws Exception { + KcSamlBrokerConfiguration samlBrokerConfig = KcSamlBrokerConfiguration.INSTANCE; + ClientRepresentation samlClient = samlBrokerConfig.createProviderClients().get(0); + IdentityProviderRepresentation samlBroker = samlBrokerConfig.setUpIdentityProvider(); + RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName()); + + try { + updateExecutions(AbstractBrokerTest::disableUpdateProfileOnFirstLogin); + adminClient.realm(bc.providerRealmName()).clients().create(samlClient); + consumerRealm.identityProviders().create(samlBroker); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + + logInWithBroker(samlBrokerConfig); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); + + testingClient.server(bc.consumerRealmName()).run(configurePostBrokerLoginWithOTP(bc.getIDPAlias())); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + + logInWithBroker(bc); + + waitForPage(driver, "account already exists", false); + idpConfirmLinkPage.assertCurrent(); + idpConfirmLinkPage.clickLinkAccount(); + loginPage.clickSocial(samlBrokerConfig.getIDPAlias()); + + totpPage.assertCurrent(); + String totpSecret = totpPage.getTotpSecret(); + totpPage.configure(totp.generateTOTP(totpSecret)); + logoutFromRealm(getConsumerRoot(), bc.consumerRealmName()); + + assertNumFederatedIdentities(consumerRealm.users().search(samlBrokerConfig.getUserLogin()).get(0).getId(), 2); + } finally { + updateExecutions(AbstractBrokerTest::setUpMissingUpdateProfileOnFirstLogin); + removeUserByUsername(consumerRealm, "consumer"); + } + } + + + @Test + public void testReauthenticationBothBrokersWithOTPRequired() throws Exception { + final RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName()); + final RealmResource providerRealm = adminClient.realm(bc.providerRealmName()); + + try (RealmAttributeUpdater rauConsumer = new RealmAttributeUpdater(consumerRealm).setOtpPolicyCodeReusable(true).update(); + RealmAttributeUpdater rauProvider = new RealmAttributeUpdater(providerRealm).setOtpPolicyCodeReusable(true).update()) { + + KcSamlBrokerConfiguration samlBrokerConfig = KcSamlBrokerConfiguration.INSTANCE; + ClientRepresentation samlClient = samlBrokerConfig.createProviderClients().get(0); + IdentityProviderRepresentation samlBroker = samlBrokerConfig.setUpIdentityProvider(); + + try { + updateExecutions(AbstractBrokerTest::disableUpdateProfileOnFirstLogin); + providerRealm.clients().create(samlClient); + consumerRealm.identityProviders().create(samlBroker); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + + testingClient.server(bc.consumerRealmName()).run(configurePostBrokerLoginWithOTP(samlBrokerConfig.getIDPAlias())); + logInWithBroker(samlBrokerConfig); + totpPage.assertCurrent(); + String totpSecret = totpPage.getTotpSecret(); + totpPage.configure(totp.generateTOTP(totpSecret)); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); + + testingClient.server(bc.consumerRealmName()).run(configurePostBrokerLoginWithOTP(bc.getIDPAlias())); + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + + logInWithBroker(bc); + + waitForPage(driver, "account already exists", false); + idpConfirmLinkPage.assertCurrent(); + idpConfirmLinkPage.clickLinkAccount(); + loginPage.clickSocial(samlBrokerConfig.getIDPAlias()); + + loginTotpPage.assertCurrent(); + loginTotpPage.login(totp.generateTOTP(totpSecret)); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + + logInWithBroker(bc); + + loginTotpPage.assertCurrent(); + loginTotpPage.login(totp.generateTOTP(totpSecret)); + + assertNumFederatedIdentities(consumerRealm.users().search(samlBrokerConfig.getUserLogin()).get(0).getId(), 2); + } finally { + updateExecutions(AbstractBrokerTest::setUpMissingUpdateProfileOnFirstLogin); + removeUserByUsername(consumerRealm, "consumer"); + } + } + } + + + @Test + public void testPostBrokerLoginFlowWithOTP() { + updateExecutions(AbstractBrokerTest::disableUpdateProfileOnFirstLogin); + + // Setup with default client scope - OTP required + configurePostBrokerLoginWithClientScopeConditionAndOTP(testingClient, bc.consumerRealmName(), bc.getIDPAlias(), OAuth2Constants.SCOPE_PROFILE, false); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + logInWithBroker(bc); + + totpPage.assertCurrent(); + String totpSecret = totpPage.getTotpSecret(); + totpPage.configure(totp.generateTOTP(totpSecret)); + + RealmResource realm = adminClient.realm(bc.consumerRealmName()); + assertNumFederatedIdentities(realm.users().search(bc.getUserLogin()).get(0).getId(), 1); + + appPage.assertCurrent(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); + + // Setup with optional client scope - scope not present in scope parameter, OTP not required + configurePostBrokerLoginWithClientScopeConditionAndOTP(testingClient, bc.consumerRealmName(), bc.getIDPAlias(), OAuth2Constants.SCOPE_PHONE, false); + setOtpTimeOffset(DEFAULT_INTERVAL_SECONDS, totp); + + loginPage.open(bc.consumerRealmName()); + logInWithBroker(bc); + + appPage.assertCurrent(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); + + // Setup with optional client scope - scope parameter present, OTP required + oauth.scope("openid phone"); + + loginPage.open(bc.consumerRealmName()); + logInWithBroker(bc); + + loginTotpPage.assertCurrent(); + loginTotpPage.login(totp.generateTOTP(totpSecret)); + + appPage.assertCurrent(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); + + // Setup with optional client scope with negate - scope parameter present, OTP not required + configurePostBrokerLoginWithClientScopeConditionAndOTP(testingClient, bc.consumerRealmName(), bc.getIDPAlias(), OAuth2Constants.SCOPE_PHONE, true); + + oauth.scope("openid phone"); + + loginPage.open(bc.consumerRealmName()); + logInWithBroker(bc); + + appPage.assertCurrent(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + AccountHelper.logout(adminClient.realm(bc.providerRealmName()), bc.getUserLogin()); + } + + static void configurePostBrokerLoginWithClientScopeConditionAndOTP(KeycloakTestingClient testingClient, String consumerRealmName, String idpAlias, String clientScopeName, boolean negate) { + testingClient.server(consumerRealmName).run(session -> { + AuthenticationFlowModel flowModel = session.getContext().getRealm().getFlowByAlias("post-broker"); + if (flowModel == null) { + flowModel = FlowUtil.createFlowModel("post-broker", "basic-flow", "post-broker flow with client-scope condition and OTP", true, false); + session.getContext().getRealm().addAuthenticationFlow(flowModel); + } + + FlowUtil.inCurrentRealm(session) + // Select new flow + .selectFlow("post-broker") + .clear() + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, AllowAccessAuthenticatorFactory.PROVIDER_ID) + .addSubFlowExecution("OTP requested when client scope", AuthenticationFlow.BASIC_FLOW, AuthenticationExecutionModel.Requirement.CONDITIONAL, (flowUtil) -> { + flowUtil.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, ConditionalClientScopeAuthenticatorFactory.PROVIDER_ID, (configModel) -> { + Map config = new HashMap<>(); + config.put(ConditionalClientScopeAuthenticatorFactory.CLIENT_SCOPE, clientScopeName); + config.put(ConditionalClientScopeAuthenticatorFactory.CONF_NEGATE, String.valueOf(negate)); + configModel.setConfig(config); + configModel.setAlias("condition - client scope"); + }) + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, OTPFormAuthenticatorFactory.PROVIDER_ID); + }); + IdentityProviderModel idp = session.identityProviders().getByAlias(idpAlias); + idp.setPostBrokerLoginFlowId(flowModel.getId()); + session.identityProviders().update(idp); + }); + } + +}