From 77b58275ca06d1cbe430c51db74479a7e1b409b5 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Tue, 7 May 2024 19:20:54 -0300 Subject: [PATCH] Improvements to the organization authentication flow Closes #29416 Closes #29417 Closes #29418 Signed-off-by: Pedro Igor --- .../models/utils/ModelToRepresentation.java | 7 +- .../admin/resource/OrganizationResource.java | 8 +- .../browser/OrganizationAuthenticator.java | 158 +++++++---- ...izationAwareAuthenticationContextBean.java | 59 ++++ ...OrganizationAwareIdentityProviderBean.java | 11 +- .../model/OrganizationAwareRealmBean.java | 16 ++ ...DeclarativeUserProfileProviderFactory.java | 1 + .../AbstractBrokerSelfRegistrationTest.java | 266 +++++++++++++++--- .../admin/AbstractOrganizationTest.java | 5 + ...zationOIDCBrokerSelfRegistrationTest.java} | 2 +- .../theme/base/login/login-username.ftl | 2 +- .../main/resources/theme/base/login/login.ftl | 2 +- 12 files changed, 421 insertions(+), 116 deletions(-) create mode 100644 services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareAuthenticationContextBean.java create mode 100644 services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareRealmBean.java rename testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/{OrganizationBrokerSelfRegistrationTest.java => OrganizationOIDCBrokerSelfRegistrationTest.java} (89%) diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 861fd90dd2..c56b87fda0 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -17,6 +17,7 @@ package org.keycloak.models.utils; +import static java.util.Optional.ofNullable; import static org.keycloak.models.utils.StripSecretsUtils.stripSecrets; import org.jboss.logging.Logger; @@ -55,13 +56,13 @@ import org.keycloak.utils.StringUtil; import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -463,7 +464,7 @@ public class ModelToRepresentation { rep.setWebAuthnPolicyPasswordlessExtraOrigins(webAuthnPolicy.getExtraOrigins()); CibaConfig cibaPolicy = realm.getCibaPolicy(); - Map attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + Map attrMap = ofNullable(rep.getAttributes()).orElse(new HashMap<>()); attrMap.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE, cibaPolicy.getBackchannelTokenDeliveryMode()); attrMap.put(CibaConfig.CIBA_EXPIRES_IN, String.valueOf(cibaPolicy.getExpiresIn())); attrMap.put(CibaConfig.CIBA_INTERVAL, String.valueOf(cibaPolicy.getPoolingInterval())); @@ -826,7 +827,7 @@ public class ModelToRepresentation { ProtocolMapperRepresentation rep = new ProtocolMapperRepresentation(); rep.setId(model.getId()); rep.setProtocol(model.getProtocol()); - Map config = new HashMap<>(model.getConfig()); + Map config = new HashMap<>(ofNullable(model.getConfig()).orElse(Collections.emptyMap())); rep.setConfig(config); rep.setName(model.getName()); rep.setProtocolMapper(model.getProtocolMapper()); diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java index 6bddaa105c..4649b97f33 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationResource.java @@ -17,8 +17,8 @@ package org.keycloak.organization.admin.resource; -import java.util.Comparator; -import java.util.Optional; +import static java.util.Optional.ofNullable; + import java.util.Set; import java.util.Objects; import java.util.stream.Collectors; @@ -78,7 +78,7 @@ public class OrganizationResource { throw ErrorResponse.error("Organization cannot be null.", Response.Status.BAD_REQUEST); } - Set domains = organization.getDomains().stream().map(OrganizationDomainRepresentation::getName).collect(Collectors.toSet()); + Set domains = ofNullable(organization.getDomains()).orElse(Set.of()).stream().map(OrganizationDomainRepresentation::getName).collect(Collectors.toSet()); OrganizationModel model = provider.create(organization.getName(), domains); toModel(organization, model); @@ -198,7 +198,7 @@ public class OrganizationResource { model.setEnabled(rep.isEnabled()); model.setDescription(rep.getDescription()); model.setAttributes(rep.getAttributes()); - model.setDomains(Optional.ofNullable(rep.getDomains()).orElse(Set.of()).stream() + model.setDomains(ofNullable(rep.getDomains()).orElse(Set.of()).stream() .filter(Objects::nonNull) .map(this::toModel) .collect(Collectors.toSet())); diff --git a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java index 7db94addb4..fe8d036f44 100644 --- a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java +++ b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java @@ -24,6 +24,8 @@ import jakarta.ws.rs.core.MultivaluedMap; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticator; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean; import org.keycloak.forms.login.freemarker.model.IdentityProviderBean; import org.keycloak.http.HttpRequest; import org.keycloak.models.FederatedIdentityModel; @@ -32,8 +34,13 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; import org.keycloak.organization.OrganizationProvider; +import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareAuthenticationContextBean; import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareIdentityProviderBean; +import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareRealmBean; +import org.keycloak.services.messages.Messages; +import org.keycloak.services.validation.Validation; public class OrganizationAuthenticator extends IdentityProviderAuthenticator { @@ -68,8 +75,6 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { return; } - OrganizationProvider provider = getOrganizationProvider(); - OrganizationModel organization = null; RealmModel realm = context.getRealm(); UserModel user = session.users().getUserByEmail(realm, username); @@ -80,67 +85,31 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { return; } - organization = provider.getByMember(user); + IdentityProviderModel broker = resolveBroker(user); - if (organization != null) { - if (provider.isManagedMember(organization, user)) { - // user is a managed member, try to resolve the origin broker and redirect automatically - List organizationBrokers = organization.getIdentityProviders().toList(); - List originBrokers = session.users().getFederatedIdentitiesStream(realm, user) - .map(f -> { - IdentityProviderModel broker = realm.getIdentityProviderByAlias(f.getIdentityProvider()); - - if (!organizationBrokers.contains(broker)) { - return null; - } - - FederatedIdentityModel identity = session.users().getFederatedIdentity(realm, user, broker.getAlias()); - - if (identity != null) { - return broker; - } - - return null; - }).filter(Objects::nonNull) - .toList(); - - - if (originBrokers.size() == 1) { - redirect(context, originBrokers.get(0).getAlias()); - return; - } - } else { - context.attempted(); - return; - } + if (broker == null) { + // not a managed member, continue with the regular flow + context.attempted(); + } else { + // user is a managed member and associated with a broker, redirect automatically + redirect(context, broker.getAlias(), user.getEmail()); } + + return; } - if (organization == null) { - organization = provider.getByDomainName(emailDomain); - } + OrganizationProvider provider = getOrganizationProvider(); + OrganizationModel organization = provider.getByDomainName(emailDomain); - if (organization == null) { + if (organization == null || !organization.isEnabled()) { // request does not map to any organization, go to the next step/sub-flow context.attempted(); return; } - List domainBrokers = organization.getIdentityProviders().toList(); + List brokers = organization.getIdentityProviders().toList(); - if (domainBrokers.isEmpty()) { - // no organization brokers to automatically redirect the user, go to the next step/sub-flow - context.attempted(); - return; - } - - if (domainBrokers.size() == 1) { - // there is a single broker, redirect the user to authenticate - redirect(context, domainBrokers.get(0).getAlias(), username); - return; - } - - for (IdentityProviderModel broker : domainBrokers) { + for (IdentityProviderModel broker : brokers) { String idpDomain = broker.getConfig().get(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); if (emailDomain.equals(idpDomain)) { @@ -150,31 +119,98 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { } } - // the user is authenticating in the scope of the organization, show the identity-first login page and the + if (!hasPublicBrokers(brokers)) { + // the user does not exist, and there is no broker available for selection, redirect the user to the identity-first login page at the realm + challenge(username, context); + return; + } + + // the user does not exist and is authenticating in the scope of the organization, show the identity-first login page and the // public organization brokers for selection - context.challenge(context.form() + LoginFormsProvider form = context.form() .setAttributeMapper(attributes -> { attributes.computeIfPresent("social", (key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, session, true) ); + attributes.computeIfPresent("auth", + (key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false) + ); + attributes.computeIfPresent("realm", + (key, bean) -> new OrganizationAwareRealmBean(realm) + ); return attributes; - }) + }); + form.addError(new FormMessage("Your email domain matches the " + organization.getName() + " organization but you don't have an account yet.")); + context.challenge(form .createLoginUsername()); } + private static boolean hasPublicBrokers(List brokers) { + return brokers.stream().anyMatch(p -> Boolean.parseBoolean(p.getConfig().getOrDefault(OrganizationModel.BROKER_PUBLIC, Boolean.FALSE.toString()))); + } + + private IdentityProviderModel resolveBroker(UserModel user) { + OrganizationProvider provider = session.getProvider(OrganizationProvider.class); + RealmModel realm = session.getContext().getRealm(); + OrganizationModel organization = provider.getByMember(user); + + if (organization == null || !organization.isEnabled()) { + return null; + } + + if (provider.isManagedMember(organization, user)) { + // user is a managed member, try to resolve the origin broker and redirect automatically + List organizationBrokers = organization.getIdentityProviders().toList(); + List brokers = session.users().getFederatedIdentitiesStream(realm, user) + .map(f -> { + IdentityProviderModel broker = realm.getIdentityProviderByAlias(f.getIdentityProvider()); + + if (!organizationBrokers.contains(broker)) { + return null; + } + + FederatedIdentityModel identity = session.users().getFederatedIdentity(realm, user, broker.getAlias()); + + if (identity != null) { + return broker; + } + + return null; + }).filter(Objects::nonNull) + .toList(); + + return brokers.size() == 1 ? brokers.get(0) : null; + } + + return null; + } + private OrganizationProvider getOrganizationProvider() { return session.getProvider(OrganizationProvider.class); } - private void challenge(AuthenticationFlowContext context){ + private void challenge(AuthenticationFlowContext context) { + challenge(null, context); + } + + private void challenge(String username, AuthenticationFlowContext context){ // the default challenge won't show any broker but just the identity-first login page and the option to try a different authentication mechanism - context.challenge(context.form() + LoginFormsProvider form = context.form() .setAttributeMapper(attributes -> { - // removes identity provider related attributes from forms - attributes.remove("social"); + attributes.computeIfPresent("social", + (key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, session, false, true) + ); + attributes.computeIfPresent("auth", + (key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false) + ); return attributes; - }) - .createLoginUsername()); + }); + + if (username != null) { + form.addError(new FormMessage(Validation.FIELD_USERNAME, Messages.INVALID_USER)); + } + + context.challenge(form.createLoginUsername()); } private String getEmailDomain(String email) { diff --git a/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareAuthenticationContextBean.java b/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareAuthenticationContextBean.java new file mode 100644 index 0000000000..352f7dc1a3 --- /dev/null +++ b/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareAuthenticationContextBean.java @@ -0,0 +1,59 @@ +/* + * 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.organization.forms.login.freemarker.model; + +import java.util.List; + +import org.keycloak.authentication.AuthenticationSelectionOption; +import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean; + +public class OrganizationAwareAuthenticationContextBean extends AuthenticationContextBean { + + private final AuthenticationContextBean delegate; + private final boolean showTryAnotherWayLink; + + public OrganizationAwareAuthenticationContextBean(AuthenticationContextBean delegate, boolean showTryAnotherWayLink) { + super(null, null); + this.delegate = delegate; + this.showTryAnotherWayLink = showTryAnotherWayLink; + } + + @Override + public List getAuthenticationSelections() { + return delegate.getAuthenticationSelections(); + } + + public boolean showTryAnotherWayLink() { + if (showTryAnotherWayLink) { + return delegate.showTryAnotherWayLink(); + } + return false; + } + + public boolean showUsername() { + return delegate.showUsername(); + } + + public boolean showResetCredentials() { + return delegate.showResetCredentials(); + } + + public String getAttemptedUsername() { + return delegate.getAttemptedUsername(); + } +} diff --git a/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareIdentityProviderBean.java b/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareIdentityProviderBean.java index dc889726aa..8b0c9a87cd 100644 --- a/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareIdentityProviderBean.java +++ b/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareIdentityProviderBean.java @@ -19,6 +19,7 @@ package org.keycloak.organization.forms.login.freemarker.model; import java.util.List; import java.util.Optional; +import java.util.function.Predicate; import org.keycloak.forms.login.freemarker.model.IdentityProviderBean; import org.keycloak.models.IdentityProviderModel; @@ -32,8 +33,16 @@ public class OrganizationAwareIdentityProviderBean extends IdentityProviderBean private final List providers; public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, KeycloakSession session, boolean onlyOrganizationBrokers) { + this(delegate, session, onlyOrganizationBrokers, false); + } + + public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, KeycloakSession session, boolean onlyOrganizationBrokers, boolean onlyRealmBrokers) { this.session = session; - if (onlyOrganizationBrokers) { + if (onlyRealmBrokers) { + providers = Optional.ofNullable(delegate.getProviders()).orElse(List.of()).stream() + .filter(Predicate.not(this::isPublicOrganizationBroker)) + .toList(); + } else if (onlyOrganizationBrokers) { providers = Optional.ofNullable(delegate.getProviders()).orElse(List.of()).stream() .filter(this::isPublicOrganizationBroker) .toList(); diff --git a/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareRealmBean.java b/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareRealmBean.java new file mode 100644 index 0000000000..e22e6bcfb3 --- /dev/null +++ b/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareRealmBean.java @@ -0,0 +1,16 @@ +package org.keycloak.organization.forms.login.freemarker.model; + +import org.keycloak.forms.login.freemarker.model.RealmBean; +import org.keycloak.models.RealmModel; + +public class OrganizationAwareRealmBean extends RealmBean { + + public OrganizationAwareRealmBean(RealmModel realmModel) { + super(realmModel); + } + + @Override + public boolean isRegistrationAllowed() { + return false; + } +} diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java index 9dc99e0b5c..e8a4e66cf5 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java @@ -358,6 +358,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide metadata.addAttribute(OrganizationModel.ORGANIZATION_ATTRIBUTE, -1, new AttributeValidatorMetadata(OrganizationMemberValidator.ID), new AttributeValidatorMetadata(ImmutableAttributeValidator.ID)) + .addReadCondition(c -> USER_API.equals(c.getContext())) .addWriteCondition(context -> { // the attribute can only be managed within the scope of the Organization API // we assume, for now, that if the organization is set as a session attribute, we are operating within the scope if the Organization API diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractBrokerSelfRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractBrokerSelfRegistrationTest.java index b7bf2ef569..5143d8f131 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractBrokerSelfRegistrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractBrokerSelfRegistrationTest.java @@ -76,7 +76,7 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz } @Test - public void testDefaultAuthenticationMechanismIfNotOrganizationMember() { + public void testDefaultAuthenticationIfUserDoesNotExistAndNoOrgMatch() { testRealm().organizations().get(createOrganization().getId()); oauth.clientId("broker-app"); @@ -85,12 +85,171 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz log.debug("Logging in"); Assert.assertFalse(loginPage.isPasswordInputPresent()); loginPage.loginUsername("user@noorg.org"); - + waitForPage(driver, "sign in to", true); + Assert.assertTrue("Driver should be on the consumer realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); // check if the login page is shown Assert.assertTrue(loginPage.isUsernameInputPresent()); Assert.assertTrue(loginPage.isPasswordInputPresent()); } + @Test + public void testIdentityFirstIfUserNotExistsAndEmailMatchOrgDomain() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + IdentityProviderRepresentation idpRep = organization.identityProviders().getIdentityProviders().get(0); + idpRep.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); + testRealm().identityProviders().get(idpRep.getAlias()).update(idpRep); + oauth.clientId("broker-app"); + + // login with email only + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + loginPage.loginUsername("user@neworg.org"); + + // should stay at the identity-first login page + waitForPage(driver, "sign in to", true); + Assert.assertTrue("Driver should be on the consumer realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); + Assert.assertTrue(loginPage.isUsernameInputPresent()); + // registration link shown + Assert.assertTrue(loginPage.isRegisterLinkPresent()); + // no need for password because the user does not exist + Assert.assertFalse(loginPage.isPasswordInputPresent()); + Assert.assertFalse(loginPage.isSocialButtonPresent(idpRep.getAlias())); + } + + @Test + public void testIdentityFirstUserNotExistEmailMatchBrokerDomainAndBrokerIsPublic() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + IdentityProviderRepresentation idpRep = organization.identityProviders().getIdentityProviders().get(0); + idpRep.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()); + idpRep.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); + testRealm().identityProviders().get(idpRep.getAlias()).update(idpRep); + oauth.clientId("broker-app"); + + // login with email only + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + Assert.assertTrue(loginPage.isRegisterLinkPresent()); + loginPage.loginUsername("user@neworg.org"); + + // should stay at the identity-first login page + waitForPage(driver, "sign in to", true); + Assert.assertTrue("Driver should be on the consumer realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); + Assert.assertEquals("Your email domain matches the neworg organization but you don't have an account yet.", loginPage.getError()); + Assert.assertTrue(loginPage.isUsernameInputPresent()); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + Assert.assertTrue(loginPage.isSocialButtonPresent(idpRep.getAlias())); + + // no self-registration link because the user should register through the broker + Assert.assertFalse(loginPage.isRegisterLinkPresent()); + } + + @Test + public void testIdentityFirstUserNotExistEmailMatchBrokerDomainNoPublicBroker() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + IdentityProviderRepresentation idpRep = organization.identityProviders().getIdentityProviders().get(0); + idpRep.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); + testRealm().identityProviders().get(idpRep.getAlias()).update(idpRep); + oauth.clientId("broker-app"); + + // login with email only + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + Assert.assertTrue(loginPage.isRegisterLinkPresent()); + loginPage.loginUsername("user@neworg.org"); + + // should stay at the identity-first login page + waitForPage(driver, "sign in to", true); + Assert.assertTrue("Driver should be on the consumer realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); + Assert.assertFalse(driver.getPageSource().contains("Your email domain matches the neworg organization but you don't have an account yet.")); + Assert.assertTrue(loginPage.isUsernameInputPresent()); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + // self-registration link shown because there is no public broker and user can choose to register + Assert.assertTrue(loginPage.isRegisterLinkPresent()); + } + + @Test + public void testDefaultAuthenticationShowsPublicOrganizationBrokers() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + OrganizationRepresentation representation = organization.toRepresentation(); + representation.addDomain(new OrganizationDomainRepresentation("other.org")); + organization.update(representation).close(); + IdentityProviderRepresentation idp = organization.identityProviders().get(bc.getIDPAlias()).toRepresentation(); + idp.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, "neworg.org"); + // set a domain to the existing broker + testRealm().identityProviders().get(bc.getIDPAlias()).update(idp); + + idp = bc.setUpIdentityProvider(); + idp.setAlias("second-idp"); + idp.setInternalId(null); + idp.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); + idp.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()); + // create a second broker without a domain set + testRealm().identityProviders().create(idp).close(); + getCleanup().addCleanup(testRealm().identityProviders().get("second-idp")::remove); + organization.identityProviders().addIdentityProvider(idp.getAlias()).close(); + idp = organization.identityProviders().get(idp.getAlias()).toRepresentation(); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); + Assert.assertFalse(loginPage.isSocialButtonPresent(idp.getAlias())); + loginPage.loginUsername("external@user.org"); + waitForPage(driver, "sign in to", true); + Assert.assertTrue("Driver should be on the consumer realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); + Assert.assertTrue(loginPage.isPasswordInputPresent()); + Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); + Assert.assertTrue(loginPage.isSocialButtonPresent(idp.getAlias())); + + waitForPage(driver, "sign in to", true); + Assert.assertTrue("Driver should be on the consumer realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); + idp.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.FALSE.toString()); + testRealm().identityProviders().get(idp.getAlias()).update(idp); + driver.navigate().refresh(); + Assert.assertTrue(loginPage.isPasswordInputPresent()); + Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); + Assert.assertFalse(loginPage.isSocialButtonPresent(idp.getAlias())); + } + + @Test + public void testDefaultAuthenticationWhenUserExistEmailMatchOrgDomain() { + realmsResouce().realm(bc.consumerRealmName()).users() + .create(UserBuilder.create() + .username("user@neworg.org") + .email("user@neworg.org") + .password(bc.getUserPassword()) + .enabled(true).build() + ).close(); + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + IdentityProviderRepresentation idpRep = organization.identityProviders().getIdentityProviders().get(0); + idpRep.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); + testRealm().identityProviders().get(idpRep.getAlias()).update(idpRep); + oauth.clientId("broker-app"); + + // login with email only + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + loginPage.loginUsername("user@neworg.org"); + + waitForPage(driver, "sign in to", true); + Assert.assertTrue("Driver should be on the consumer realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); + Assert.assertTrue(loginPage.isUsernameInputPresent()); + Assert.assertTrue(loginPage.isPasswordInputPresent()); + Assert.assertTrue(loginPage.isRegisterLinkPresent()); + } + @Test public void testRealmLevelBrokersAvailableIfEmailDoesNotMatchOrganization() { testRealm().organizations().get(createOrganization().getId()); @@ -131,12 +290,20 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz ).close(); OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + OrganizationIdentityProviderResource broker = organization.identityProviders().get(bc.getIDPAlias()); + IdentityProviderRepresentation brokerRep = broker.toRepresentation(); + brokerRep.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()); + testRealm().identityProviders().get(brokerRep.getAlias()).update(brokerRep); oauth.clientId("broker-app"); // login with email only loginPage.open(bc.consumerRealmName()); log.debug("Logging in"); loginPage.loginUsername(bc.getUserEmail()); + waitForPage(driver, "sign in to", true); + Assert.assertTrue("Driver should be on the provider realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); + loginPage.clickSocial(bc.getIDPAlias()); // user automatically redirected to the organization identity provider waitForPage(driver, "sign in to", true); @@ -162,7 +329,57 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz } @Test - public void testReAuthenticateWhenAlreadyMember() { + public void testExistingUserUsingOrgDomain() { + // create a realm user in the consumer realm + realmsResouce().realm(bc.consumerRealmName()).users() + .create(UserBuilder.create() + .username(bc.getUserLogin()) + .email(bc.getUserEmail()) + .password(bc.getUserPassword()) + .enabled(true).build() + ).close(); + + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + OrganizationIdentityProviderResource broker = organization.identityProviders().get(bc.getIDPAlias()); + IdentityProviderRepresentation brokerRep = broker.toRepresentation(); + brokerRep.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()); + testRealm().identityProviders().get(brokerRep.getAlias()).update(brokerRep); + oauth.clientId("broker-app"); + + // login with email only + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + loginPage.loginUsername(bc.getUserEmail()); + waitForPage(driver, "sign in to", true); + Assert.assertTrue("Driver should be on the provider realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); + loginPage.clickSocial(bc.getIDPAlias()); + + // user automatically redirected to the organization identity provider + waitForPage(driver, "sign in to", true); + Assert.assertTrue("Driver should be on the provider realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.providerRealmName() + "/")); + + // login to the organization identity provider and run the configured first broker login flow + loginPage.login(bc.getUserEmail(), bc.getUserPassword()); + waitForPage(driver, "update account information", false); + updateAccountInformationPage.assertCurrent(); + Assert.assertTrue("We must be on correct realm right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); + log.debug("Updating info on updateAccount page"); + updateAccountInformationPage.updateAccountInformation(bc.getUserEmail(), bc.getUserEmail(), "Firstname", "Lastname"); + + // account with the same email exists in the realm, execute account linking + waitForPage(driver, "account already exists", false); + idpConfirmLinkPage.assertCurrent(); + idpConfirmLinkPage.clickLinkAccount(); + // confirm the link by authenticating + loginPage.login(bc.getUserEmail(), bc.getUserPassword()); + assertIsMember(bc.getUserEmail(), organization); + } + + @Test + public void testRedirectBrokerWhenManagedMember() { OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); // add the member for the first time @@ -281,47 +498,6 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz assertEquals(bc.getIDPAlias(), federatedIdentities.get(0).getIdentityProvider()); } - @Test - public void testIdentityFirstLoginShowsPublicOrganizationBrokers() { - OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); - OrganizationRepresentation representation = organization.toRepresentation(); - representation.addDomain(new OrganizationDomainRepresentation("other.org")); - organization.update(representation).close(); - IdentityProviderRepresentation idp = organization.identityProviders().get(bc.getIDPAlias()).toRepresentation(); - idp.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, "neworg.org"); - // set a domain to the existing broker - testRealm().identityProviders().get(bc.getIDPAlias()).update(idp); - - idp = bc.setUpIdentityProvider(); - idp.setAlias("second-idp"); - idp.setInternalId(null); - idp.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); - idp.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()); - // create a second broker without a domain set - testRealm().identityProviders().create(idp).close(); - getCleanup().addCleanup(testRealm().identityProviders().get("second-idp")::remove); - organization.identityProviders().addIdentityProvider(idp.getAlias()).close(); - idp = organization.identityProviders().get(idp.getAlias()).toRepresentation(); - - oauth.clientId("broker-app"); - loginPage.open(bc.consumerRealmName()); - log.debug("Logging in"); - Assert.assertFalse(loginPage.isPasswordInputPresent()); - Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); - Assert.assertFalse(loginPage.isSocialButtonPresent(idp.getAlias())); - loginPage.loginUsername("external@user.org"); - Assert.assertTrue(loginPage.isPasswordInputPresent()); - Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); - Assert.assertTrue(loginPage.isSocialButtonPresent(idp.getAlias())); - - idp.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.FALSE.toString()); - testRealm().identityProviders().get(idp.getAlias()).update(idp); - driver.navigate().refresh(); - Assert.assertTrue(loginPage.isPasswordInputPresent()); - Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); - Assert.assertFalse(loginPage.isSocialButtonPresent(idp.getAlias())); - } - @Test public void testLoginUsingBrokerWithoutDomain() { OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); @@ -506,6 +682,7 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz // make sure the user can select this idp from the organization when authenticating idpRep.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()); + idpRep.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); testRealm().identityProviders().get(idpRep.getAlias()).update(idpRep); // create a user to the provider realm using a email that does not share the same domain as the org @@ -544,6 +721,7 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz // make sure the user can select this idp from the organization when authenticating idpRep.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()); + idpRep.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); testRealm().identityProviders().get(idpRep.getAlias()).update(idpRep); // create a user to the provider realm using a email that does not share the same domain as the org diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java index cd885f8e61..80b5e5f2cf 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java @@ -18,6 +18,7 @@ package org.keycloak.testsuite.organization.admin; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage; @@ -28,6 +29,8 @@ import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import org.jboss.arquillian.graphene.page.Page; import org.keycloak.admin.client.resource.OrganizationResource; +import org.keycloak.models.OrganizationModel; +import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.admin.client.resource.UsersResource; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.admin.client.resource.RealmResource; @@ -102,6 +105,7 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest { id = ApiUtil.getCreatedId(response); } IdentityProviderRepresentation broker = brokerConfigFunction.apply(name).setUpIdentityProvider(); + broker.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, org.getDomains().iterator().next().getName()); testRealm().identityProviders().create(broker).close(); getCleanup().addCleanup(testRealm().identityProviders().get(broker.getAlias())::remove); testRealm().organizations().get(id).identityProviders().addIdentityProvider(broker.getAlias()).close(); @@ -183,6 +187,7 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest { Assert.assertTrue("We must be on correct realm right now", driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); log.debug("Updating info on updateAccount page"); + assertFalse(driver.getPageSource().contains("kc.org")); updateAccountInformationPage.updateAccountInformation(bc.getUserLogin(), email, "Firstname", "Lastname"); assertIsMember(email, organization); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationBrokerSelfRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationOIDCBrokerSelfRegistrationTest.java similarity index 89% rename from testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationBrokerSelfRegistrationTest.java rename to testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationOIDCBrokerSelfRegistrationTest.java index de1cdc07b5..9bf8b307d1 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationBrokerSelfRegistrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationOIDCBrokerSelfRegistrationTest.java @@ -21,6 +21,6 @@ import org.keycloak.common.Profile.Feature; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; @EnableFeature(Feature.ORGANIZATION) -public class OrganizationBrokerSelfRegistrationTest extends AbstractBrokerSelfRegistrationTest { +public class OrganizationOIDCBrokerSelfRegistrationTest extends AbstractBrokerSelfRegistrationTest { } diff --git a/themes/src/main/resources/theme/base/login/login-username.ftl b/themes/src/main/resources/theme/base/login/login-username.ftl index be77ccbe4c..4e0d312ce8 100755 --- a/themes/src/main/resources/theme/base/login/login-username.ftl +++ b/themes/src/main/resources/theme/base/login/login-username.ftl @@ -62,7 +62,7 @@ <#elseif section = "socialProviders" > - <#if realm.password && social?? && social.providers??> + <#if realm.password && social?? && social.providers?has_content>

${msg("identity-provider-login-label")}

diff --git a/themes/src/main/resources/theme/base/login/login.ftl b/themes/src/main/resources/theme/base/login/login.ftl index 0b36ee9554..a7361e9f46 100755 --- a/themes/src/main/resources/theme/base/login/login.ftl +++ b/themes/src/main/resources/theme/base/login/login.ftl @@ -88,7 +88,7 @@
<#elseif section = "socialProviders" > - <#if realm.password && social?? && social.providers??> + <#if realm.password && social?? && social.providers?has_content>

${msg("identity-provider-login-label")}