From 32d25f43d047e2029ce6dca1f450d07551734206 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Tue, 30 Apr 2024 16:04:09 -0300 Subject: [PATCH] Support for mutiple identity providers Closes #28840 Signed-off-by: Pedro Igor --- .../idm/OrganizationDomainRepresentation.java | 8 + .../OrganizationIdentityProviderResource.java | 5 - ...OrganizationIdentityProvidersResource.java | 44 ++ .../client/resource/OrganizationResource.java | 4 +- .../jpa/entities/OrganizationEntity.java | 11 - .../jpa/JpaOrganizationProvider.java | 40 +- .../organization/jpa/OrganizationAdapter.java | 4 +- .../META-INF/jpa-changelog-25.0.0.xml | 1 - .../organization/OrganizationProvider.java | 6 +- .../models/IdentityProviderModel.java | 8 + .../keycloak/models/OrganizationModel.java | 4 +- .../FreeMarkerLoginFormsProvider.java | 11 +- .../OrganizationIdentityProviderResource.java | 188 -------- ...OrganizationIdentityProvidersResource.java | 138 ++++++ .../admin/resource/OrganizationResource.java | 6 +- ...dpAddOrganizationMemberAuthenticator.java} | 36 +- .../IdpOrganizationAuthenticatorFactory.java | 8 +- .../browser/OrganizationAuthenticator.java | 124 +++-- ...OrganizationAwareIdentityProviderBean.java | 43 +- .../OrganizationMemberValidator.java | 36 +- .../resources/LoginActionsService.java | 19 +- .../admin/AbstractOrganizationTest.java | 26 +- .../OrganizationAdminPermissionsTest.java | 16 +- ...rganizationBrokerSelfRegistrationTest.java | 446 +++++++++++++++++- .../OrganizationIdentityProviderTest.java | 114 +++-- .../OrganizationMemberAuthenticationTest.java | 2 +- .../admin/OrganizationMemberTest.java | 15 +- 27 files changed, 991 insertions(+), 372 deletions(-) create mode 100644 integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProvidersResource.java delete mode 100644 services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProviderResource.java create mode 100644 services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java rename services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/{IdpOrganizationAuthenticator.java => IdpAddOrganizationMemberAuthenticator.java} (74%) rename services/src/main/java/org/keycloak/organization/{authentication/authenticators/browser => forms/login/freemarker/model}/OrganizationAwareIdentityProviderBean.java (52%) diff --git a/core/src/main/java/org/keycloak/representations/idm/OrganizationDomainRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/OrganizationDomainRepresentation.java index 1f17f4c52a..02d99452f5 100644 --- a/core/src/main/java/org/keycloak/representations/idm/OrganizationDomainRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/OrganizationDomainRepresentation.java @@ -27,6 +27,14 @@ public class OrganizationDomainRepresentation { private String name; private boolean verified; + public OrganizationDomainRepresentation() { + // for reflection + } + + public OrganizationDomainRepresentation(String name) { + this.name = name; + } + public String getName() { return this.name; } diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProviderResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProviderResource.java index 68c153b5c1..7b0631ce98 100644 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProviderResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProviderResource.java @@ -20,7 +20,6 @@ package org.keycloak.admin.client.resource; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; @@ -29,10 +28,6 @@ import org.keycloak.representations.idm.IdentityProviderRepresentation; public interface OrganizationIdentityProviderResource { - @POST - @Consumes(MediaType.APPLICATION_JSON) - Response create(IdentityProviderRepresentation idpRepresentation); - @GET @Produces(MediaType.APPLICATION_JSON) IdentityProviderRepresentation toRepresentation(); diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProvidersResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProvidersResource.java new file mode 100644 index 0000000000..1a00203a66 --- /dev/null +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProvidersResource.java @@ -0,0 +1,44 @@ +/* + * 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.admin.client.resource; + +import java.util.List; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.keycloak.representations.idm.IdentityProviderRepresentation; + +public interface OrganizationIdentityProvidersResource { + + @POST + @Consumes(MediaType.APPLICATION_JSON) + Response create(IdentityProviderRepresentation idpRepresentation); + + @GET + @Produces(MediaType.APPLICATION_JSON) + List getIdentityProviders(); + + @Path("{id}") + OrganizationIdentityProviderResource get(@PathParam("id") String id); +} diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationResource.java index d6c3b12432..7c696739d0 100644 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationResource.java @@ -43,6 +43,6 @@ public interface OrganizationResource { @Path("members") OrganizationMembersResource members(); - @Path("identity-provider") - OrganizationIdentityProviderResource identityProvider(); + @Path("identity-providers") + OrganizationIdentityProvidersResource identityProviders(); } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java index ee4909cd19..5ea596af79 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/OrganizationEntity.java @@ -57,9 +57,6 @@ public class OrganizationEntity { @Column(name = "GROUP_ID") private String groupId; - @Column(name = "IDP_ALIAS") - private String idpAlias; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy="organization") protected Set domains = new HashSet<>(); @@ -95,14 +92,6 @@ public class OrganizationEntity { return name; } - public String getIdpAlias() { - return idpAlias; - } - - public void setIdpAlias(String idpAlias) { - this.idpAlias = idpAlias; - } - public Collection getDomains() { if (this.domains == null) { this.domains = new HashSet<>(); diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java index cffd24a555..f314dce940 100644 --- a/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/JpaOrganizationProvider.java @@ -23,6 +23,7 @@ import static org.keycloak.utils.StreamsUtil.closing; import java.util.List; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -98,8 +99,7 @@ public class JpaOrganizationProvider implements OrganizationProvider { //TODO: won't scale, requires a better mechanism for bulk deleting users userProvider.getGroupMembersStream(realm, group).forEach(userModel -> removeMember(organization, userModel)); groupProvider.removeGroup(realm, group); - - realm.removeIdentityProviderByAlias(entity.getIdpAlias()); + organization.getIdentityProviders().forEach((model) -> realm.removeIdentityProviderByAlias(model.getAlias())); em.remove(entity); @@ -215,29 +215,35 @@ public class JpaOrganizationProvider implements OrganizationProvider { throwExceptionIfObjectIsNull(identityProvider, "Identity provider"); OrganizationEntity organizationEntity = getEntity(organization.getId()); - organizationEntity.setIdpAlias(identityProvider.getAlias()); - identityProvider.getConfig().put(ORGANIZATION_ATTRIBUTE, organization.getId()); + + identityProvider.setOrganizationId(organizationEntity.getId()); realm.updateIdentityProvider(identityProvider); return true; } @Override - public IdentityProviderModel getIdentityProvider(OrganizationModel organization) { + public Stream getIdentityProviders(OrganizationModel organization) { throwExceptionIfObjectIsNull(organization, "Organization"); throwExceptionIfObjectIsNull(organization.getId(), "Organization ID"); OrganizationEntity organizationEntity = getEntity(organization.getId()); - // realm and its IDPs are cached - return realm.getIdentityProviderByAlias(organizationEntity.getIdpAlias()); + + return realm.getIdentityProvidersStream().filter(model -> organizationEntity.getId().equals(model.getOrganizationId())); } @Override - public boolean removeIdentityProvider(OrganizationModel organization) { + public boolean removeIdentityProvider(OrganizationModel organization, IdentityProviderModel identityProvider) { throwExceptionIfObjectIsNull(organization, "Organization"); OrganizationEntity organizationEntity = getEntity(organization.getId()); - organizationEntity.setIdpAlias(null); + + if (!organizationEntity.getId().equals(identityProvider.getOrganizationId())) { + return false; + } + + realm.removeIdentityProviderByAlias(identityProvider.getAlias()); + return true; } @@ -249,15 +255,19 @@ public class JpaOrganizationProvider implements OrganizationProvider { return false; } - IdentityProviderModel identityProvider = organization.getIdentityProvider(); + List brokers = organization.getIdentityProviders().toList(); - if (identityProvider == null) { + if (brokers.isEmpty()) { return false; } - FederatedIdentityModel federatedIdentity = userProvider.getFederatedIdentity(realm, member, identityProvider.getAlias()); + List federatedIdentities = userProvider.getFederatedIdentitiesStream(realm, member) + .map(federatedIdentityModel -> realm.getIdentityProviderByAlias(federatedIdentityModel.getIdentityProvider())) + .filter(brokers::contains) + .map(m -> userProvider.getFederatedIdentity(realm, member, m.getAlias())) + .toList(); - return federatedIdentity != null; + return !federatedIdentities.isEmpty(); } @Override @@ -310,12 +320,12 @@ public class JpaOrganizationProvider implements OrganizationProvider { } if (!realm.getId().equals(entity.getRealmId())) { - throw new ModelException("Organization [" + entity.getId() + " does not belong to realm [" + realm.getId() + "]"); + throw new ModelException("Organization [" + entity.getId() + "] does not belong to realm [" + realm.getId() + "]"); } return entity; } - + private GroupModel createOrganizationGroup(String name) { throwExceptionIfObjectIsNull(name, "Name of the group"); diff --git a/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java b/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java index aedc715275..52a1342d67 100644 --- a/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/organization/jpa/OrganizationAdapter.java @@ -130,8 +130,8 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel getIdentityProviders() { + return provider.getIdentityProviders(this); } @Override diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml index 54820c5673..5cc3ad8c31 100644 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-25.0.0.xml @@ -89,7 +89,6 @@ - diff --git a/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java b/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java index 14e909b9ef..ea6a3205ef 100644 --- a/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/organization/OrganizationProvider.java @@ -16,6 +16,7 @@ */ package org.keycloak.organization; +import java.util.List; import java.util.Set; import java.util.stream.Stream; import org.keycloak.models.IdentityProviderModel; @@ -140,15 +141,16 @@ public interface OrganizationProvider extends Provider { * @param organization the organization * @return The identityProvider associated with a given {@code organization} or {@code null} if there is none. */ - IdentityProviderModel getIdentityProvider(OrganizationModel organization); + Stream getIdentityProviders(OrganizationModel organization); /** * Removes the link between the given {@link OrganizationModel} and identity provider associated with it if such a link exists. * * @param organization the organization + * @param identityProvider the identity provider * @return {@code true} if the link was removed, {@code false} otherwise */ - boolean removeIdentityProvider(OrganizationModel organization); + boolean removeIdentityProvider(OrganizationModel organization, IdentityProviderModel identityProvider); /** * Indicates if the current realm supports organization. diff --git a/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java b/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java index d44fb8435b..8298fef496 100755 --- a/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java +++ b/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java @@ -220,6 +220,14 @@ public class IdentityProviderModel implements Serializable { return displayIconClasses; } + public String getOrganizationId() { + return getConfig().get(OrganizationModel.ORGANIZATION_ATTRIBUTE); + } + + public void setOrganizationId(String organizationId) { + getConfig().put(OrganizationModel.ORGANIZATION_ATTRIBUTE, organizationId); + } + /** *

Validates this configuration. * diff --git a/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java b/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java index facdf90011..a28897eba5 100644 --- a/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java +++ b/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java @@ -25,6 +25,8 @@ import java.util.stream.Stream; public interface OrganizationModel { String ORGANIZATION_ATTRIBUTE = "kc.org"; + String ORGANIZATION_DOMAIN_ATTRIBUTE = "kc.org.domain"; + String BROKER_PUBLIC = "kc.org.broker.public"; String getId(); @@ -40,7 +42,7 @@ public interface OrganizationModel { void setDomains(Set domains); - IdentityProviderModel getIdentityProvider(); + Stream getIdentityProviders(); boolean isManaged(UserModel user); } diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index 447088ad3b..a863693559 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -30,6 +30,8 @@ import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticator; import org.keycloak.authentication.requiredactions.util.UpdateProfileContext; import org.keycloak.authentication.requiredactions.util.UserUpdateProfileContext; import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; import org.keycloak.common.util.ObjectUtil; import org.keycloak.forms.login.LoginFormsPages; import org.keycloak.forms.login.LoginFormsProvider; @@ -64,6 +66,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.FormMessage; +import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareIdentityProviderBean; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.rar.AuthorizationDetails; import org.keycloak.representations.idm.OAuth2ErrorRepresentation; @@ -71,7 +74,6 @@ import org.keycloak.services.Urls; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.LoginActionsService; import org.keycloak.sessions.AuthenticationSessionModel; -import org.keycloak.sessions.CommonClientSessionModel; import org.keycloak.theme.FreeMarkerException; import org.keycloak.theme.Theme; import org.keycloak.theme.beans.AdvancedMessageFormatterMethod; @@ -474,8 +476,13 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { List identityProviders = LoginFormsUtil .filterIdentityProvidersForTheme(realm.getIdentityProvidersStream(), session, context); - attributes.put("social", new IdentityProviderBean(realm, session, identityProviders, baseUriWithCodeAndClientId)); + IdentityProviderBean idpBean = new IdentityProviderBean(realm, session, identityProviders, baseUriWithCodeAndClientId); + if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { + idpBean = new OrganizationAwareIdentityProviderBean(idpBean, session); + } + + attributes.put("social", idpBean); attributes.put("url", new UrlBean(realm, theme, baseUri, this.actionUri)); attributes.put("requiredActionUrl", new RequiredActionUrlFormatterMethod(realm, baseUri)); attributes.put("auth", new AuthenticationContextBean(context, page)); diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProviderResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProviderResource.java deleted file mode 100644 index 16e20f8223..0000000000 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProviderResource.java +++ /dev/null @@ -1,188 +0,0 @@ -/* - * 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.admin.resource; - -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.DELETE; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.PUT; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.Status; -import jakarta.ws.rs.ext.Provider; -import java.util.Objects; -import java.util.Optional; - -import org.keycloak.models.IdentityProviderModel; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.ModelException; -import org.keycloak.models.OrganizationModel; -import org.keycloak.models.RealmModel; -import org.keycloak.models.utils.ModelToRepresentation; -import org.keycloak.organization.OrganizationProvider; -import org.keycloak.representations.idm.IdentityProviderRepresentation; -import org.keycloak.services.ErrorResponse; -import org.keycloak.services.resources.admin.AdminEventBuilder; -import org.keycloak.services.resources.admin.IdentityProviderResource; -import org.keycloak.services.resources.admin.IdentityProvidersResource; -import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; - -@Provider -public class OrganizationIdentityProviderResource { - - private final KeycloakSession session; - private final RealmModel realm; - private final OrganizationProvider organizationProvider; - private final OrganizationModel organization; - private final AdminPermissionEvaluator auth; - private final AdminEventBuilder adminEvent; - - public OrganizationIdentityProviderResource() { - // needed for registering to the JAX-RS stack - this(null, null, null, null); - } - - public OrganizationIdentityProviderResource(KeycloakSession session, OrganizationModel organization, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) { - this.session = session; - this.realm = session == null ? null : session.getContext().getRealm(); - this.organizationProvider = session == null ? null : session.getProvider(OrganizationProvider.class); - this.organization = organization; - this.auth = auth; - this.adminEvent = adminEvent; - } - - @POST - @Consumes(MediaType.APPLICATION_JSON) - public Response addIdentityProvider(IdentityProviderRepresentation providerRep) { - - auth.realm().requireManageRealm(); - - IdentityProviderModel identityProvider = organization.getIdentityProvider(); - if (identityProvider != null) { - throw ErrorResponse.error("Organization already assigned with an identity provider.", Status.BAD_REQUEST); - } - - //create IdP within the realm - Response response = new IdentityProvidersResource(realm, session, auth, adminEvent).create(providerRep); - - if (Status.CREATED.getStatusCode() == response.getStatus()) { - - //get the created IdP from session - identityProvider = realm.getIdentityProviderByAlias(providerRep.getAlias()); - - String errorMessage; - try { - if (organizationProvider.addIdentityProvider(organization, identityProvider)) { - return response; - } - errorMessage = "Assigning the Identity provider with the organization was not succesful."; - } catch (ModelException me) { - errorMessage = me.getMessage(); - } - throw ErrorResponse.error(errorMessage, Status.BAD_REQUEST); - } - - return response; - } - - @GET - @Produces(MediaType.APPLICATION_JSON) - public IdentityProviderRepresentation getIdentityProvider() { - auth.realm().requireManageRealm(); - return Optional.ofNullable(organization.getIdentityProvider()).map(this::toRepresentation).orElse(null); - } - - @DELETE - public Response delete() { - auth.realm().requireManageRealm(); - IdentityProviderModel identityProvider = getIdentityProviderModel(); - - Response response = getIdentityProviderResource(identityProvider).delete(); - - // remove link between IdP and the organization if the IdP deletetion was successful - if (Status.NO_CONTENT.getStatusCode() == response.getStatus()) { - String errorMessage; - try { - if (organizationProvider.removeIdentityProvider(organization)) { - return response; - } - errorMessage = "Removing the Identity provider from the organization was not succesful."; - } catch (ModelException me) { - errorMessage = me.getMessage(); - } - throw ErrorResponse.error(errorMessage, Status.BAD_REQUEST); - } - - return response; - } - - @PUT - @Consumes(MediaType.APPLICATION_JSON) - public Response update(IdentityProviderRepresentation rep) { - auth.realm().requireManageRealm(); - IdentityProviderModel identityProvider = getIdentityProviderModel(); - - if (!rep.getAlias().equals(identityProvider.getAlias()) || (rep.getInternalId() != null && !Objects.equals(rep.getInternalId(), identityProvider.getInternalId()))) { - throw ErrorResponse.error("Identity provider not assigned to the organization.", Status.NOT_FOUND); - } - - Response response = getIdentityProviderResource(identityProvider).update(rep); - - //update link between IdP and the organization if the update of IdP was successful and the IdP alias differs - if (Status.NO_CONTENT.getStatusCode() == response.getStatus() && - ! Objects.equals(identityProvider.getAlias(), rep.getAlias())) { - - //get the updated IdP from session - identityProvider = realm.getIdentityProviderByAlias(rep.getAlias()); - - String errorMessage; - try { - if (organizationProvider.removeIdentityProvider(organization) && - organizationProvider.addIdentityProvider(organization, identityProvider)) { - return response; - } - errorMessage = "Updating the Identity provider was not succesful."; - } catch (ModelException me) { - errorMessage = me.getMessage(); - } - throw ErrorResponse.error(errorMessage, Status.BAD_REQUEST); - } - - return response; - } - - private IdentityProviderRepresentation toRepresentation(IdentityProviderModel idp) { - return ModelToRepresentation.toRepresentation(realm, idp); - } - - private IdentityProviderResource getIdentityProviderResource(IdentityProviderModel idp) { - return new IdentityProviderResource(auth, realm, session, idp, adminEvent); - } - - private IdentityProviderModel getIdentityProviderModel() { - IdentityProviderModel identityProvider = organization.getIdentityProvider(); - - if (identityProvider == null) { - throw ErrorResponse.error("Organization doesn't have assigned an identity provider.", Status.NOT_FOUND); - } - - return identityProvider; - } -} diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java new file mode 100644 index 0000000000..220333a022 --- /dev/null +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java @@ -0,0 +1,138 @@ +/* + * 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.admin.resource; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.ext.Provider; + +import java.util.List; +import java.util.stream.Stream; + +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; +import org.keycloak.models.OrganizationDomainModel; +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.organization.OrganizationProvider; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.services.ErrorResponse; +import org.keycloak.services.resources.admin.AdminEventBuilder; +import org.keycloak.services.resources.admin.IdentityProviderResource; +import org.keycloak.services.resources.admin.IdentityProvidersResource; +import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; + +@Provider +public class OrganizationIdentityProvidersResource { + + private final KeycloakSession session; + private final RealmModel realm; + private final OrganizationProvider organizationProvider; + private final OrganizationModel organization; + private final AdminPermissionEvaluator auth; + private final AdminEventBuilder adminEvent; + + public OrganizationIdentityProvidersResource() { + // needed for registering to the JAX-RS stack + this(null, null, null, null); + } + + public OrganizationIdentityProvidersResource(KeycloakSession session, OrganizationModel organization, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) { + this.session = session; + this.realm = session == null ? null : session.getContext().getRealm(); + this.organizationProvider = session == null ? null : session.getProvider(OrganizationProvider.class); + this.organization = organization; + this.auth = auth; + this.adminEvent = adminEvent; + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + public Response addIdentityProvider(IdentityProviderRepresentation providerRep) { + auth.realm().requireManageRealm(); + + Response response = new IdentityProvidersResource(realm, session, auth, adminEvent).create(providerRep); + + if (Status.CREATED.getStatusCode() == response.getStatus()) { + try { + IdentityProviderModel identityProvider = realm.getIdentityProviderByAlias(providerRep.getAlias()); + + if (organizationProvider.addIdentityProvider(organization, identityProvider)) { + return response; + } + + throw ErrorResponse.error("Identity provider already associated to the organization", Status.BAD_REQUEST); + } catch (ModelException me) { + throw ErrorResponse.error(me.getMessage(), Status.BAD_REQUEST); + } + } + + return response; + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Stream getIdentityProviders() { + auth.realm().requireManageRealm(); + return organization.getIdentityProviders().map(this::toRepresentation); + } + + @Path("{alias}") + public IdentityProviderResource getIdentityProvider(@PathParam("alias") String alias) { + IdentityProviderModel broker = realm.getIdentityProviderByAlias(alias); + return new IdentityProviderResource(auth, realm, session, broker, adminEvent) { + @Override + public Response delete() { + Response response = super.delete(); + + if (organizationProvider.removeIdentityProvider(organization, broker)) { + return response; + } + + throw ErrorResponse.error("Identity provider not associated with the organization", Status.BAD_REQUEST); + } + + @Override + public Response update(IdentityProviderRepresentation providerRep) { + if (organization.getIdentityProviders().noneMatch(model -> model.getInternalId().equals(providerRep.getInternalId()) || model.getAlias().equals(providerRep.getAlias()))) { + return Response.status(Status.NOT_FOUND).build(); + } + String domain = providerRep.getConfig().get(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); + + if (domain != null && organization.getDomains().map(OrganizationDomainModel::getName).noneMatch(domain::equals)) { + return Response.status(Status.BAD_REQUEST).build(); + } + + return super.update(providerRep); + } + }; + } + + private IdentityProviderRepresentation toRepresentation(IdentityProviderModel idp) { + return ModelToRepresentation.toRepresentation(realm, idp); + } +} 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 cfdab06783..5fb2fe8630 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 @@ -142,9 +142,9 @@ public class OrganizationResource { return new OrganizationMemberResource(session, organization, auth, adminEvent); } - @Path("{id}/identity-provider") - public OrganizationIdentityProviderResource identityProvider(@PathParam("id") String id) { - return new OrganizationIdentityProviderResource(session, getOrganization(id), auth, adminEvent); + @Path("{id}/identity-providers") + public OrganizationIdentityProvidersResource identityProvider(@PathParam("id") String id) { + return new OrganizationIdentityProvidersResource(session, getOrganization(id), auth, adminEvent); } private OrganizationModel getOrganization(String id) { diff --git a/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpOrganizationAuthenticator.java b/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpAddOrganizationMemberAuthenticator.java similarity index 74% rename from services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpOrganizationAuthenticator.java rename to services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpAddOrganizationMemberAuthenticator.java index 4dad9c1d05..55216f39e4 100644 --- a/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpOrganizationAuthenticator.java +++ b/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpAddOrganizationMemberAuthenticator.java @@ -17,6 +17,9 @@ package org.keycloak.organization.authentication.authenticators.broker; +import java.util.List; +import java.util.stream.Stream; + import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; @@ -29,7 +32,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.organization.OrganizationProvider; -public class IdpOrganizationAuthenticator extends AbstractIdpAuthenticator { +public class IdpAddOrganizationMemberAuthenticator extends AbstractIdpAuthenticator { @Override protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) { @@ -46,10 +49,10 @@ public class IdpOrganizationAuthenticator extends AbstractIdpAuthenticator { return; } - IdentityProviderModel expectedBroker = organization.getIdentityProvider(); - IdentityProviderModel currentBroker = brokerContext.getIdpConfig(); + Stream expectedBrokers = organization.getIdentityProviders(); + IdentityProviderModel broker = brokerContext.getIdpConfig(); - if (!expectedBroker.getAlias().equals(currentBroker.getAlias())) { + if (expectedBrokers.noneMatch(broker::equals)) { context.failure(AuthenticationFlowError.ACCESS_DENIED); return; } @@ -71,31 +74,12 @@ public class IdpOrganizationAuthenticator extends AbstractIdpAuthenticator { return false; } - String domain = getEmailDomain(user.getEmail()); + OrganizationModel organization = (OrganizationModel) session.getAttribute(OrganizationModel.class.getName()); - if (domain == null) { + if (organization == null) { return false; } - OrganizationModel organization = provider.getByDomainName(domain); - - if (organization == null || provider.getIdentityProvider(organization) == null) { - return false; - } - - session.setAttribute(OrganizationModel.class.getName(), organization); - - return true; + return provider.getIdentityProviders(organization).findAny().isPresent(); } - - private String getEmailDomain(String email) { - int domainSeparator = email.indexOf('@'); - - if (domainSeparator == -1) { - return null; - } - - return email.substring(domainSeparator + 1); - } - } \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpOrganizationAuthenticatorFactory.java b/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpOrganizationAuthenticatorFactory.java index 7a278b2cc4..a2ff2baa69 100644 --- a/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpOrganizationAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpOrganizationAuthenticatorFactory.java @@ -33,11 +33,11 @@ import org.keycloak.provider.ProviderConfigProperty; public class IdpOrganizationAuthenticatorFactory implements AuthenticatorFactory, EnvironmentDependentProviderFactory { - public static final String ID = "organization-broker"; + public static final String ID = "idp-add-organization-member"; @Override public Authenticator create(KeycloakSession session) { - return new IdpOrganizationAuthenticator(); + return new IdpAddOrganizationMemberAuthenticator(); } @Override @@ -77,7 +77,7 @@ public class IdpOrganizationAuthenticatorFactory implements AuthenticatorFactory @Override public String getDisplayType() { - return "Organization Member Link"; + return "Organization"; } @Override @@ -87,7 +87,7 @@ public class IdpOrganizationAuthenticatorFactory implements AuthenticatorFactory @Override public boolean isUserSetupAllowed() { - return false; + return true; } @Override 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 c7d11b5fce..7db94addb4 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 @@ -17,18 +17,23 @@ package org.keycloak.organization.authentication.authenticators.browser; -import java.util.function.BiFunction; +import java.util.List; +import java.util.Objects; 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.freemarker.model.IdentityProviderBean; import org.keycloak.http.HttpRequest; +import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationModel; +import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.organization.OrganizationProvider; +import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareIdentityProviderBean; public class OrganizationAuthenticator extends IdentityProviderAuthenticator { @@ -43,7 +48,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { OrganizationProvider provider = getOrganizationProvider(); if (!provider.isEnabled()) { - attempted(context); + context.attempted(); return; } @@ -55,55 +60,114 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { HttpRequest request = context.getHttpRequest(); MultivaluedMap parameters = request.getDecodedFormParameters(); String username = parameters.getFirst(UserModel.USERNAME); + String emailDomain = getEmailDomain(username); - if (username == null) { - challenge(context); - return; - } - - String domain = getEmailDomain(username); - - if (domain == null) { - attempted(context); + if (emailDomain == null) { + // username does not map to any email domain, go to the next authentication step/sub-flow + context.attempted(); return; } OrganizationProvider provider = getOrganizationProvider(); - OrganizationModel organization = provider.getByDomainName(domain); + OrganizationModel organization = null; + RealmModel realm = context.getRealm(); + UserModel user = session.users().getUserByEmail(realm, username); + + if (user != null) { + // user exists, check if enabled + if (!user.isEnabled()) { + context.failure(AuthenticationFlowError.INVALID_USER); + return; + } + + organization = provider.getByMember(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 (organization == null) { - attempted(context); + organization = provider.getByDomainName(emailDomain); + } + + if (organization == null) { + // request does not map to any organization, go to the next step/sub-flow + context.attempted(); return; } - IdentityProviderModel identityProvider = organization.getIdentityProvider(); + List domainBrokers = organization.getIdentityProviders().toList(); - if (identityProvider == null) { - attempted(context); + if (domainBrokers.isEmpty()) { + // no organization brokers to automatically redirect the user, go to the next step/sub-flow + context.attempted(); return; } - redirect(context, identityProvider.getAlias(), username); - } + if (domainBrokers.size() == 1) { + // there is a single broker, redirect the user to authenticate + redirect(context, domainBrokers.get(0).getAlias(), username); + return; + } - private void attempted(AuthenticationFlowContext context) { - context.form() + for (IdentityProviderModel broker : domainBrokers) { + String idpDomain = broker.getConfig().get(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); + + if (emailDomain.equals(idpDomain)) { + // redirect the user using the broker that matches the email domain + redirect(context, broker.getAlias(), username); + return; + } + } + + // the user 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() .setAttributeMapper(attributes -> { - attributes.computeIfPresent("social", createOrganizationAwareSocialBean()); + attributes.computeIfPresent("social", + (key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, session, true) + ); return attributes; - }); - context.attempted(); - } - - private BiFunction createOrganizationAwareSocialBean() { - return (key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, session); + }) + .createLoginUsername()); } private OrganizationProvider getOrganizationProvider() { return session.getProvider(OrganizationProvider.class); } - private void challenge (AuthenticationFlowContext context){ + private void challenge(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() .setAttributeMapper(attributes -> { // removes identity provider related attributes from forms @@ -114,6 +178,10 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { } private String getEmailDomain(String email) { + if (email == null) { + return null; + } + int domainSeparator = email.indexOf('@'); if (domainSeparator == -1) { diff --git a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAwareIdentityProviderBean.java b/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareIdentityProviderBean.java similarity index 52% rename from services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAwareIdentityProviderBean.java rename to services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareIdentityProviderBean.java index afc06a805b..dc889726aa 100644 --- a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAwareIdentityProviderBean.java +++ b/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareIdentityProviderBean.java @@ -15,10 +15,9 @@ * limitations under the License. */ -package org.keycloak.organization.authentication.authenticators.browser; +package org.keycloak.organization.forms.login.freemarker.model; import java.util.List; -import java.util.Map; import java.util.Optional; import org.keycloak.forms.login.freemarker.model.IdentityProviderBean; @@ -29,30 +28,50 @@ import org.keycloak.models.RealmModel; public class OrganizationAwareIdentityProviderBean extends IdentityProviderBean { - private final IdentityProviderBean delegate; private final KeycloakSession session; + private final List providers; - public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, KeycloakSession session) { - this.delegate = delegate; + public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, KeycloakSession session, boolean onlyOrganizationBrokers) { this.session = session; + if (onlyOrganizationBrokers) { + providers = Optional.ofNullable(delegate.getProviders()).orElse(List.of()).stream() + .filter(this::isPublicOrganizationBroker) + .toList(); + } else { + providers = Optional.ofNullable(delegate.getProviders()).orElse(List.of()).stream() + .filter(p -> isRealmBroker(p) || isPublicOrganizationBroker(p)) + .toList(); + } + } + public OrganizationAwareIdentityProviderBean(IdentityProviderBean delegate, KeycloakSession session) { + this(delegate, session, false); } @Override public List getProviders() { - return Optional.ofNullable(delegate.getProviders()).orElse(List.of()).stream() - .filter(this::filterOrganizationalIdentityProvider) - .toList(); + return providers; } @Override public boolean isDisplayInfo() { - return delegate.isDisplayInfo(); + return !providers.isEmpty(); } - private boolean filterOrganizationalIdentityProvider(IdentityProvider idp) { + private boolean isPublicOrganizationBroker(IdentityProvider idp) { RealmModel realm = session.getContext().getRealm(); IdentityProviderModel model = realm.getIdentityProviderByAlias(idp.getAlias()); - Map config = model.getConfig(); - return !config.containsKey(OrganizationModel.ORGANIZATION_ATTRIBUTE); + + if (model.getOrganizationId() == null) { + return false; + } + + return Boolean.parseBoolean(model.getConfig().getOrDefault(OrganizationModel.BROKER_PUBLIC, Boolean.FALSE.toString())); + } + + private boolean isRealmBroker(IdentityProvider idp) { + RealmModel realm = session.getContext().getRealm(); + IdentityProviderModel model = realm.getIdentityProviderByAlias(idp.getAlias()); + + return model.getOrganizationId() == null; } } diff --git a/services/src/main/java/org/keycloak/organization/validator/OrganizationMemberValidator.java b/services/src/main/java/org/keycloak/organization/validator/OrganizationMemberValidator.java index 05980d0948..d7a05764a9 100644 --- a/services/src/main/java/org/keycloak/organization/validator/OrganizationMemberValidator.java +++ b/services/src/main/java/org/keycloak/organization/validator/OrganizationMemberValidator.java @@ -19,11 +19,13 @@ package org.keycloak.organization.validator; import static org.keycloak.validate.BuiltinValidators.emailValidator; -import java.util.stream.Stream; +import java.util.List; import org.keycloak.Config.Scope; +import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.common.Profile; import org.keycloak.common.Profile.Feature; +import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationDomainModel; import org.keycloak.models.OrganizationModel; @@ -32,6 +34,7 @@ import org.keycloak.organization.OrganizationProvider; import org.keycloak.provider.EnvironmentDependentProviderFactory; import org.keycloak.userprofile.AttributeContext; import org.keycloak.userprofile.UserProfileAttributeValidationContext; +import org.keycloak.userprofile.UserProfileContext; import org.keycloak.utils.StringUtil; import org.keycloak.validate.AbstractSimpleValidator; import org.keycloak.validate.ValidationContext; @@ -83,15 +86,38 @@ public class OrganizationMemberValidator extends AbstractSimpleValidator impleme UserProfileAttributeValidationContext upContext = (UserProfileAttributeValidationContext) context; AttributeContext attributeContext = upContext.getAttributeContext(); UserModel user = attributeContext.getUser(); + String emailDomain = email.substring(email.indexOf('@') + 1); + List expectedDomains = organization.getDomains().map(OrganizationDomainModel::getName).toList(); - if (!organization.isManaged(user)) { + if (UserProfileContext.IDP_REVIEW.equals(attributeContext.getContext())) { + KeycloakSession session = attributeContext.getSession(); + BrokeredIdentityContext brokerContext = (BrokeredIdentityContext) session.getAttribute(BrokeredIdentityContext.class.getName()); + + if (brokerContext != null) { + String alias = brokerContext.getIdpConfig().getAlias(); + IdentityProviderModel broker = organization.getIdentityProviders().filter((p) -> p.getAlias().equals(alias)).findAny().orElse(null); + + if (broker == null) { + return; + } + + String brokerDomain = broker.getConfig().get(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); + + if (brokerDomain == null) { + return; + } + + expectedDomains = List.of(brokerDomain); + } + } else if (!organization.isManaged(user)) { return; } - String domain = email.substring(email.indexOf('@') + 1); - Stream expectedDomains = organization.getDomains(); + if (expectedDomains.isEmpty()) { + return; + } - if (expectedDomains.map(OrganizationDomainModel::getName).noneMatch(domain::equals)) { + if (!expectedDomains.contains(emailDomain)) { context.addError(new ValidationError(ID, inputHint, "Email domain does not match any domain from the organization")); } } diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index 56f55caee5..71fc06a9bb 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -17,6 +17,8 @@ package org.keycloak.services.resources; import org.jboss.logging.Logger; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.MessageType; import org.keycloak.forms.login.freemarker.DetachedInfoStateChecker; @@ -52,6 +54,7 @@ import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; import org.keycloak.exceptions.TokenNotActiveException; import org.keycloak.models.KeycloakContext; +import org.keycloak.models.OrganizationModel; import org.keycloak.models.SingleUseObjectKeyModel; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.ClientModel; @@ -67,6 +70,7 @@ import org.keycloak.models.utils.AuthenticationFlowResolver; import org.keycloak.models.utils.FormMessage; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.SystemClientUtil; +import org.keycloak.organization.OrganizationProvider; import org.keycloak.protocol.AuthorizationEndpointBase; import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol.Error; @@ -77,7 +81,6 @@ import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.representations.JsonWebToken; import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorPageException; -import org.keycloak.services.ErrorResponseException; import org.keycloak.services.ServicesLogger; import org.keycloak.services.Urls; import org.keycloak.services.managers.AuthenticationManager; @@ -925,9 +928,23 @@ public class LoginActionsService { }; + configureOrganization(brokerContext); + return processFlow(checks.isActionRequest(), execution, authSession, flowPath, brokerLoginFlow, null, processor); } + private void configureOrganization(BrokeredIdentityContext brokerContext) { + if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { + String organizationId = brokerContext.getIdpConfig().getOrganizationId(); + + if (organizationId != null) { + OrganizationProvider provider = session.getProvider(OrganizationProvider.class); + session.setAttribute(OrganizationModel.class.getName(), provider.getById(organizationId)); + session.setAttribute(BrokeredIdentityContext.class.getName(), brokerContext); + } + } + } + private Response redirectToAfterBrokerLoginEndpoint(AuthenticationSessionModel authSession, boolean firstBrokerLogin) { return redirectToAfterBrokerLoginEndpoint(session, realm, session.getContext().getUri(), authSession, firstBrokerLogin); } 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 bd4f6489a7..e870b94d15 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 @@ -27,6 +27,7 @@ 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.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.OrganizationDomainRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.RealmRepresentation; @@ -62,10 +63,18 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest { providerRealm.setClients(createProviderClients()); providerRealm.setUsers(List.of( UserBuilder.create() - .username(getUserLogin()) - .email(getUserEmail()) - .password(getUserPassword()) - .enabled(true).build()) + .username(getUserLogin()) + .email(getUserEmail()) + .password(getUserPassword()) + .enabled(true) + .build(), + UserBuilder.create() + .username("external") + .email("external@user.org") + .password("password") + .enabled(true) + .build() + ) ); return providerRealm; @@ -80,6 +89,13 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest { public String getIDPAlias() { return name + "-identity-provider"; } + + @Override + public List createProviderClients() { + List clients = super.createProviderClients(); + clients.get(0).setRedirectUris(List.of("*")); + return clients; + } }; @Page @@ -126,7 +142,7 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest { id = ApiUtil.getCreatedId(response); } - testRealm().organizations().get(id).identityProvider().create(brokerConfigFunction.apply(name).setUpIdentityProvider()).close(); + testRealm().organizations().get(id).identityProviders().create(brokerConfigFunction.apply(name).setUpIdentityProvider()).close(); org = testRealm().organizations().get(id).toRepresentation(); getCleanup().addCleanup(() -> testRealm().organizations().get(id).delete().close()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationAdminPermissionsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationAdminPermissionsTest.java index 0acdd2aed7..455acd4089 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationAdminPermissionsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationAdminPermissionsTest.java @@ -107,33 +107,33 @@ public class OrganizationAdminPermissionsTest extends AbstractOrganizationTest { idpRep.setProviderId("oidc"); //create IdP try ( - Response userResponse = realmUserResource.organizations().get(orgId).identityProvider().create(idpRep); - Response adminResponse = realmAdminResource.organizations().get(orgId).identityProvider().create(idpRep) + Response userResponse = realmUserResource.organizations().get(orgId).identityProviders().create(idpRep); + Response adminResponse = realmAdminResource.organizations().get(orgId).identityProviders().create(idpRep) ) { assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); assertThat(adminResponse.getStatus(), equalTo(Status.CREATED.getStatusCode())); - getCleanup().addCleanup(() -> testRealm().organizations().get(orgId).identityProvider().delete().close()); + getCleanup().addCleanup(() -> testRealm().organizations().get(orgId).identityProviders().get(idpRep.getAlias()).delete().close()); } //get IdP try { //we should get 403, not 400 or 404 etc. - realmUserResource.organizations().get("non-existing").identityProvider().toRepresentation(); + realmUserResource.organizations().get("non-existing").identityProviders().get(idpRep.getAlias()).toRepresentation(); fail("Expected ForbiddenException"); } catch (ForbiddenException expected) {} try { - realmUserResource.organizations().get(orgId).identityProvider().toRepresentation(); + realmUserResource.organizations().get(orgId).identityProviders().get(idpRep.getAlias()).toRepresentation(); fail("Expected ForbiddenException"); } catch (ForbiddenException expected) {} - assertThat(realmAdminResource.organizations().get(orgId).identityProvider().toRepresentation(), Matchers.notNullValue()); + assertThat(realmAdminResource.organizations().get(orgId).identityProviders().get(idpRep.getAlias()).toRepresentation(), Matchers.notNullValue()); //update IdP - try (Response userResponse = realmUserResource.organizations().get(orgId).identityProvider().update(idpRep)) { + try (Response userResponse = realmUserResource.organizations().get(orgId).identityProviders().get(idpRep.getAlias()).update(idpRep)) { assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); } //delete IdP - try (Response userResponse = realmUserResource.organizations().get(orgId).identityProvider().delete()) { + try (Response userResponse = realmUserResource.organizations().get(orgId).identityProviders().get(idpRep.getAlias()).delete()) { assertThat(userResponse.getStatus(), equalTo(Status.FORBIDDEN.getStatusCode())); } 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/OrganizationBrokerSelfRegistrationTest.java index a5b6d662b7..e5690d1641 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/OrganizationBrokerSelfRegistrationTest.java @@ -18,6 +18,8 @@ package org.keycloak.testsuite.organization.admin; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage; @@ -26,15 +28,20 @@ import java.util.List; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.NotFoundException; import org.junit.Test; +import org.keycloak.admin.client.resource.OrganizationIdentityProviderResource; import org.keycloak.admin.client.resource.OrganizationMemberResource; import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.admin.client.resource.UsersResource; import org.keycloak.common.Profile.Feature; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.OrganizationModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.representations.idm.ErrorRepresentation; +import org.keycloak.representations.idm.FederatedIdentityRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.OrganizationDomainRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; @@ -44,17 +51,17 @@ import org.keycloak.testsuite.util.UserBuilder; public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganizationTest { @Test - public void testBrokerRegistration() { + public void testRegistrationRedirectWhenSingleBroker() { OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); - assertBrokerRegistration(organization); + assertBrokerRegistration(organization, bc.getUserEmail()); } @Test - public void testLoginHint() { + public void testLoginHintSentToBrokerWhenEnabled() { OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); - IdentityProviderRepresentation idp = organization.identityProvider().toRepresentation(); + IdentityProviderRepresentation idp = organization.identityProviders().get(bc.getIDPAlias()).toRepresentation(); idp.getConfig().put(IdentityProviderModel.LOGIN_HINT, "true"); - organization.identityProvider().update(idp).close(); + organization.identityProviders().get(bc.getIDPAlias()).update(idp).close(); oauth.clientId("broker-app"); loginPage.open(bc.consumerRealmName()); @@ -71,6 +78,50 @@ public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganization Assert.assertEquals(bc.getUserEmail(), loginPage.getUsername()); } + @Test + public void testDefaultAuthenticationMechanismIfNotOrganizationMember() { + testRealm().organizations().get(createOrganization().getId()); + oauth.clientId("broker-app"); + + // login with email only + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + loginPage.loginUsername("user@noorg.org"); + + // check if the login page is shown + Assert.assertTrue(loginPage.isUsernameInputPresent()); + Assert.assertTrue(loginPage.isPasswordInputPresent()); + } + + @Test + public void testRealmLevelBrokersAvailableIfEmailDoesNotMatchOrganization() { + testRealm().organizations().get(createOrganization().getId()); + oauth.clientId("broker-app"); + + // login with email only + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + loginPage.loginUsername("user"); + + // check if the login page is shown + Assert.assertTrue(loginPage.isUsernameInputPresent()); + Assert.assertTrue(loginPage.isPasswordInputPresent()); + Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); + + IdentityProviderRepresentation idp = bc.setUpIdentityProvider(); + idp.setAlias("realm-level-idp"); + Assert.assertFalse(loginPage.isSocialButtonPresent(idp.getAlias())); + testRealm().identityProviders().create(idp).close(); + + driver.navigate().refresh(); + + Assert.assertTrue(loginPage.isUsernameInputPresent()); + Assert.assertTrue(loginPage.isPasswordInputPresent()); + Assert.assertTrue(loginPage.isSocialButtonPresent(idp.getAlias())); + } + @Test public void testLinkExistingAccount() { // create a realm user in the consumer realm @@ -102,7 +153,7 @@ public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganization 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.getUserLogin(), bc.getUserEmail(), "Firstname", "Lastname"); + 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); @@ -114,11 +165,11 @@ public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganization } @Test - public void testMemberAlreadyExists() { + public void testReAuthenticateWhenAlreadyMember() { OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); // add the member for the first time - assertBrokerRegistration(organization); + assertBrokerRegistration(organization, bc.getUserEmail()); // logout to force the user to authenticate again UserRepresentation account = getUserRepresentation(bc.getUserEmail()); @@ -141,11 +192,15 @@ public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganization } @Test - public void testFailUpdateEmailWithDomainDifferentThanOrganization() { + public void testFailUpdateEmailNotAssociatedOrganizationUsingAdminAPI() { OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + OrganizationIdentityProviderResource idp = organization.identityProviders().get(bc.getIDPAlias()); + IdentityProviderRepresentation idpRep = idp.toRepresentation(); + idpRep.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, "neworg.org"); + idp.update(idpRep).close(); // add the member for the first time - assertBrokerRegistration(organization); + assertBrokerRegistration(organization, bc.getUserEmail()); UserRepresentation member = getUserRepresentation(bc.getUserEmail()); member.setEmail(KeycloakModelUtils.generateId() + "@user.org"); @@ -165,13 +220,12 @@ public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganization } @Test - public void testDelete() { + public void testDeleteManagedMember() { OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); // add the member for the first time - assertBrokerRegistration(organization); + assertBrokerRegistration(organization, bc.getUserEmail()); UserRepresentation member = getUserRepresentation(bc.getUserEmail()); - member.setEmail(KeycloakModelUtils.generateId() + "@user.org"); OrganizationMemberResource organizationMember = organization.members().member(member.getId()); organizationMember.delete().close(); @@ -189,29 +243,368 @@ public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganization } } - private void assertBrokerRegistration(OrganizationResource organization) { - // login with email only + @Test + public void testRedirectToIdentityProviderAssociatedWithOrganizationDomain() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + IdentityProviderRepresentation idp = organization.identityProviders().get(bc.getIDPAlias()).toRepresentation(); + idp.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, "neworg.org"); + organization.identityProviders().get(bc.getIDPAlias()).update(idp).close(); + idp.setAlias("second-idp"); + idp.setInternalId(null); + idp.getConfig().remove(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE); + organization.identityProviders().create(idp).close(); + 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(bc.getUserEmail()); // 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.getUserLogin(), bc.getUserEmail(), "Firstname", "Lastname"); - + updateAccountInformationPage.updateAccountInformation(bc.getUserEmail(), bc.getUserEmail(), "Firstname", "Lastname"); + appPage.assertCurrent(); assertIsMember(bc.getUserEmail(), organization); + UserRepresentation user = testRealm().users().search(bc.getUserEmail()).get(0); + List federatedIdentities = testRealm().users().get(user.getId()).getFederatedIdentity(); + assertEquals(1, federatedIdentities.size()); + 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 + organization.identityProviders().get(bc.getIDPAlias()).update(idp).close(); + + 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 + organization.identityProviders().create(idp).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()); + organization.identityProviders().get(idp.getAlias()).update(idp).close(); + 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()); + IdentityProviderRepresentation idp = organization.identityProviders().get(bc.getIDPAlias()).toRepresentation(); + idp.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, "neworg.org"); + // set a domain to the existing broker + organization.identityProviders().get(bc.getIDPAlias()).update(idp).close(); + + 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 + organization.identityProviders().create(idp).close(); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); + String email = "external@user.org"; + loginPage.loginUsername(email); + loginPage.clickSocial(idp.getAlias()); + + // 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() + "/")); + loginPage.login("external", "password"); + 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(email, email, "Firstname", "Lastname"); + appPage.assertCurrent(); + assertIsMember(email, organization); + + // make sure the federated identity matches the expected broker + UserRepresentation user = testRealm().users().search(email).get(0); + List federatedIdentities = testRealm().users().get(user.getId()).getFederatedIdentity(); + assertEquals(1, federatedIdentities.size()); + assertEquals(idp.getAlias(), federatedIdentities.get(0).getIdentityProvider()); + } + + @Test + public void testEmailDomainDoesNotMatchBrokerDomain() { + 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 + organization.identityProviders().get(bc.getIDPAlias()).update(idp).close(); + + idp = bc.setUpIdentityProvider(); + idp.setAlias("second-idp"); + idp.setInternalId(null); + idp.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, "other.org"); + idp.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()); + // create a second broker without a domain set + organization.identityProviders().create(idp).close(); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); + String email = "external@user.org"; + loginPage.loginUsername(email); + loginPage.clickSocial(idp.getAlias()); + + // 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() + "/")); + loginPage.login(email, "password"); + 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(email, email, "Firstname", "Lastname"); + Assert.assertTrue(driver.getPageSource().contains("Email domain does not match any domain from the organization")); + assertIsNotMember(email, organization); + updateAccountInformationPage.updateAccountInformation("external@other.org", "external@other.org", "Firstname", "Lastname"); + appPage.assertCurrent(); + assertIsMember("external@other.org", organization); + } + + @Test + public void testAnyEmailFromBrokerWithoutDomainSet() { + 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 + organization.identityProviders().get(bc.getIDPAlias()).update(idp).close(); + + idp = bc.setUpIdentityProvider(); + idp.setAlias("second-idp"); + idp.setInternalId(null); + idp.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()); + // create a second broker without a domain set + organization.identityProviders().create(idp).close(); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); + String email = "external@user.org"; + loginPage.loginUsername(email); + loginPage.clickSocial(idp.getAlias()); + + // 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() + "/")); + loginPage.login(email, "password"); + 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("external@unknown.org", "external@unknown.org", "Firstname", "Lastname"); + appPage.assertCurrent(); + assertIsMember("external@unknown.org", organization); + } + + @Test + public void testRealmLevelBrokerNotImpactedByOrganizationFlow() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + IdentityProviderRepresentation idp = bc.setUpIdentityProvider(); + idp.setAlias("realm-idp"); + idp.setInternalId(null); + // create a second broker without a domain set + testRealm().identityProviders().create(idp).close(); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); + loginPage.loginUsername("some@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() + "/")); + loginPage.clickSocial(idp.getAlias()); + + 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() + "/")); + loginPage.login("external", "password"); + 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"); + appPage.assertCurrent(); + assertTrue(organization.members().getAll().isEmpty()); + + UserRepresentation user = testRealm().users().search(bc.getUserEmail()).get(0); + testRealm().users().get(user.getId()).remove(); + } + + @Test + public void testMemberRegistrationUsingDifferentDomainThanOrganization() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + IdentityProviderRepresentation idpRep = organization.identityProviders().getIdentityProviders().get(0); + + // make sure the user can select this idp from the organization when authenticating + idpRep.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()); + organization.identityProviders().get(idpRep.getAlias()).update(idpRep).close(); + + // create a user to the provider realm using a email that does not share the same domain as the org + UserRepresentation user = UserBuilder.create() + .username("user") + .email("user@different.org") + .password("password") + .enabled(true) + .build(); + realmsResouce().realm(bc.providerRealmName()).users().create(user).close(); + + // select the organization broker to authenticate + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + loginPage.loginUsername("user@different.org"); + loginPage.clickSocial(idpRep.getAlias()); + + // login through the organization broker + 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() + "/")); + loginPage.login("user@different.org", "password"); + 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(user.getUsername(), user.getEmail(), "Firstname", "Lastname"); + appPage.assertCurrent(); + } + + @Test + public void testMemberFromBrokerRedirectedToOriginBroker() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + IdentityProviderRepresentation idpRep = organization.identityProviders().getIdentityProviders().get(0); + + // make sure the user can select this idp from the organization when authenticating + idpRep.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()); + organization.identityProviders().get(idpRep.getAlias()).update(idpRep).close(); + + // create a user to the provider realm using a email that does not share the same domain as the org + UserRepresentation user = UserBuilder.create() + .username("user") + .email("user@different.org") + .password("password") + .enabled(true) + .build(); + realmsResouce().realm(bc.providerRealmName()).users().create(user).close(); + + // execute the identity-first login + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + loginPage.loginUsername(user.getEmail()); + + waitForPage(driver, "sign in to", true); + // select the organization broker to authenticate + assertTrue(loginPage.isPasswordInputPresent()); + assertTrue(loginPage.isUsernameInputPresent()); + loginPage.clickSocial(idpRep.getAlias()); + + // login through the organization broker + 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() + "/")); + loginPage.login("user@different.org", "password"); + 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(user.getUsername(), user.getEmail(), "Firstname", "Lastname"); + UserRepresentation account = getUserRepresentation(user.getEmail()); + realmsResouce().realm(bc.consumerRealmName()).users().get(account.getId()).logout(); + + // the flow now changed and the user should be automatically redirected to the origin broker + loginPage.open(bc.consumerRealmName()); + waitForPage(driver, "sign in to", true); + loginPage.loginUsername(user.getEmail()); + Assert.assertTrue("Driver should be on the provider realm page right now", + driver.getCurrentUrl().contains("/auth/realms/" + bc.providerRealmName() + "/")); + loginPage.login("user@different.org", "password"); + appPage.assertCurrent(); + } + + private void assertBrokerRegistration(OrganizationResource organization, String email) { + // login with email only + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + log.debug("Logging in"); + Assert.assertFalse(loginPage.isPasswordInputPresent()); + Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias())); + loginPage.loginUsername(email); + + // 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(email, 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.getUserLogin(), email, "Firstname", "Lastname"); + + assertIsMember(email, organization); } private void assertIsMember(String userEmail, OrganizationResource organization) { @@ -220,6 +613,23 @@ public class OrganizationBrokerSelfRegistrationTest extends AbstractOrganization Assert.assertEquals(account.getId(), member.getId()); } + private void assertIsNotMember(String userEmail, OrganizationResource organization) { + UsersResource users = adminClient.realm(bc.consumerRealmName()).users(); + List reps = users.searchByEmail(userEmail, true); + + if (reps.isEmpty()) { + return; + } + + assertEquals(1, reps.size()); + UserRepresentation account = reps.get(0); + + try { + assertNull(organization.members().member(account.getId()).toRepresentation()); + } catch (NotFoundException ignore) { + } + } + private UserRepresentation getUserRepresentation(String userEmail) { UsersResource users = adminClient.realm(bc.consumerRealmName()).users(); List reps = users.searchByEmail(userEmail, true); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationIdentityProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationIdentityProviderTest.java index 996ac9f4d1..69dab203aa 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationIdentityProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationIdentityProviderTest.java @@ -19,8 +19,8 @@ package org.keycloak.testsuite.organization.admin; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.nullValue; +import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import org.junit.Assert; @@ -28,6 +28,7 @@ import org.junit.Test; import org.keycloak.admin.client.resource.OrganizationIdentityProviderResource; import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.common.Profile.Feature; +import org.keycloak.models.OrganizationModel; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; @@ -38,61 +39,90 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest { @Test public void testUpdate() { OrganizationRepresentation organization = createOrganization(); - OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(organization.getId()).identityProvider(); + OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(organization.getId()) + .identityProviders().get(bc.getIDPAlias()); IdentityProviderRepresentation actual = orgIdPResource.toRepresentation(); IdentityProviderRepresentation expected = actual; assertThat(expected.getAlias(), equalTo(bc.getIDPAlias())); //update + expected.setAlias("changed-alias"); expected.setDisplayName("My Org Broker"); expected.getConfig().put("test", "value"); try (Response response = orgIdPResource.update(expected)) { assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); } + try { + orgIdPResource.toRepresentation(); + Assert.fail("should fail because the alias changed"); + } catch (NotFoundException ignore) { + + } + orgIdPResource = testRealm().organizations().get(organization.getId()).identityProviders().get(expected.getAlias()); actual = orgIdPResource.toRepresentation(); + assertThat(expected.getAlias(), equalTo(actual.getAlias())); assertThat(expected.getDisplayName(), equalTo(actual.getDisplayName())); Assert.assertEquals(expected.getConfig().get("test"), actual.getConfig().get("test")); } - @Test - public void testFailUpdateAlias() { - OrganizationRepresentation organization = createOrganization(); - OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(organization.getId()).identityProvider(); - IdentityProviderRepresentation idpRepresentation = orgIdPResource.toRepresentation(); - assertThat(idpRepresentation.getAlias(), equalTo(bc.getIDPAlias())); - - //update - idpRepresentation.setAlias("should-fail"); - try (Response response = orgIdPResource.update(idpRepresentation)) { - assertThat(response.getStatus(), equalTo(Status.NOT_FOUND.getStatusCode())); - } - } - @Test public void testDelete() { - OrganizationRepresentation organization = createOrganization(); - OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(organization.getId()).identityProvider(); + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + IdentityProviderRepresentation idpTemplate = organization + .identityProviders().get(bc.getIDPAlias()).toRepresentation(); - try (Response response = orgIdPResource.delete()) { - assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); + for (int i = 0; i < 5; i++) { + idpTemplate.setAlias("idp-" + i); + idpTemplate.setInternalId(null); + organization.identityProviders().create(idpTemplate).close(); } - assertThat(orgIdPResource.toRepresentation(), nullValue()); + + Assert.assertEquals(6, organization.identityProviders().getIdentityProviders().size()); + + for (int i = 0; i < 5; i++) { + OrganizationIdentityProviderResource idpResource = organization.identityProviders().get("idp-" + i); + + try (Response response = idpResource.delete()) { + assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); + } + + try { + idpResource.toRepresentation(); + Assert.fail("should be removed"); + } catch (NotFoundException expected) { + } + } + + organization.identityProviders().get(bc.getIDPAlias()).delete().close(); + + Assert.assertTrue(testRealm().identityProviders().findAll().isEmpty()); } @Test - public void tryCreateSecondIdp() { - OrganizationIdentityProviderResource orgIdPResource = testRealm().organizations().get(createOrganization().getId()).identityProvider(); + public void testCreatingExistingIdentityProvider() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + OrganizationIdentityProviderResource orgIdPResource = organization + .identityProviders().get(bc.getIDPAlias()); IdentityProviderRepresentation idpRepresentation = orgIdPResource.toRepresentation(); + String alias = idpRepresentation.getAlias(); idpRepresentation.setAlias("another-idp"); - try (Response response = orgIdPResource.create(idpRepresentation)) { - assertThat(response.getStatus(), equalTo(Response.Status.BAD_REQUEST.getStatusCode())); + + try (Response response = organization.identityProviders().create(idpRepresentation)) { + assertThat(response.getStatus(), equalTo(Status.CONFLICT.getStatusCode())); + } + + idpRepresentation.setAlias(alias); + idpRepresentation.setInternalId(null); + + try (Response response = organization.identityProviders().create(idpRepresentation)) { + assertThat(response.getStatus(), equalTo(Status.CONFLICT.getStatusCode())); } } @Test(expected = jakarta.ws.rs.NotFoundException.class) - public void removingOrgShouldRemoveIdP() { + public void testRemovingOrgShouldRemoveIdP() { OrganizationRepresentation orgRep = createOrganization(); OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); @@ -101,25 +131,27 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest { } testRealm().identityProviders().get(bc.getIDPAlias()).toRepresentation(); + Assert.assertTrue(testRealm().identityProviders().findAll().isEmpty()); } @Test - public void tryUpdateAndRemoveIdPNotAssignedToOrg() { + public void testUpdateOrDeleteIdentityProviderNotAssignedToOrganization() { OrganizationRepresentation orgRep = createOrganization(); OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); - - OrganizationIdentityProviderResource orgIdPResource = orgResource.identityProvider(); - + OrganizationIdentityProviderResource orgIdPResource = orgResource.identityProviders().get(bc.getIDPAlias()); IdentityProviderRepresentation idpRepresentation = createRep("some-broker", "oidc"); + getCleanup().addCleanup(() -> testRealm().identityProviders().get(idpRepresentation.getAlias()).remove()); //create IdP in realm not bound to Org testRealm().identityProviders().create(idpRepresentation).close(); try (Response response = orgIdPResource.update(idpRepresentation)) { assertThat(response.getStatus(), equalTo(Response.Status.NOT_FOUND.getStatusCode())); } + try (Response response = orgIdPResource.delete()) { assertThat(response.getStatus(), equalTo(Status.NO_CONTENT.getStatusCode())); } + try (Response response = orgIdPResource.delete()) { assertThat(response.getStatus(), equalTo(Status.NOT_FOUND.getStatusCode())); } @@ -130,21 +162,41 @@ public class OrganizationIdentityProviderTest extends AbstractOrganizationTest { OrganizationRepresentation orgRep = createOrganization(); OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); - OrganizationIdentityProviderResource orgIdPResource = orgResource.identityProvider(); + OrganizationIdentityProviderResource orgIdPResource = orgResource.identityProviders().get(bc.getIDPAlias()); IdentityProviderRepresentation idpRepresentation = createRep("some-broker", "oidc"); //create IdP in realm not bound to Org and get created internalId testRealm().identityProviders().create(idpRepresentation).close(); + getCleanup().addCleanup(() -> testRealm().identityProviders().get(idpRepresentation.getAlias()).remove()); String internalId = testRealm().identityProviders().get("some-broker").toRepresentation().getInternalId(); IdentityProviderRepresentation orgIdPRep = orgIdPResource.toRepresentation(); orgIdPRep.setInternalId(internalId); + try (Response response = orgIdPResource.update(orgIdPRep)) { + assertThat(response.getStatus(), equalTo(Status.CONFLICT.getStatusCode())); + } + + orgIdPRep.setAlias("some-broker-alias"); + try (Response response = orgIdPResource.update(orgIdPRep)) { assertThat(response.getStatus(), equalTo(Response.Status.NOT_FOUND.getStatusCode())); } } + @Test + public void testAssignDomainNotBoundToOrganization() { + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + OrganizationIdentityProviderResource orgIdPResource = orgResource.identityProviders().get(bc.getIDPAlias()); + IdentityProviderRepresentation idpRep = orgIdPResource.toRepresentation(); + idpRep.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, "unknown.org"); + + try (Response response = orgIdPResource.update(idpRep)) { + assertThat(response.getStatus(), equalTo(Status.BAD_REQUEST.getStatusCode())); + } + } + private IdentityProviderRepresentation createRep(String alias, String providerId) { IdentityProviderRepresentation idp = new IdentityProviderRepresentation(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberAuthenticationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberAuthenticationTest.java index 016f5f26a5..43af9bd55c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberAuthenticationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberAuthenticationTest.java @@ -43,7 +43,7 @@ public class OrganizationMemberAuthenticationTest extends AbstractOrganizationTe // the email does not match an organization so redirect to the realm's default authentication mechanism waitForPage(driver, "sign in to", true); - Assert.assertTrue("Driver should be on the provider realm page right now", + Assert.assertTrue("Driver should be on the consumer realm page right now", driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/")); Assert.assertTrue(loginPage.isPasswordInputPresent()); Assert.assertEquals(member.getEmail(), loginPage.getUsername()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberTest.java index d32f2a6deb..4cea02b590 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationMemberTest.java @@ -164,7 +164,7 @@ public class OrganizationMemberTest extends AbstractOrganizationTest { } @Test - public void testDelete() { + public void testDeleteUnmanagedMember() { UPConfig upConfig = testRealm().users().userProfile().getConfiguration(); upConfig.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ENABLED); OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); @@ -188,6 +188,19 @@ public class OrganizationMemberTest extends AbstractOrganizationTest { } } + @Test + public void testUpdateEmailUnmanagedMember() { + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + UserRepresentation expected = addMember(organization); + expected.setEmail("some@unknown.org"); + UserResource userResource = testRealm().users().get(expected.getId()); + userResource.update(expected); + UserRepresentation actual = userResource.toRepresentation(); + assertEquals(expected.getId(), actual.getId()); + assertEquals(expected.getEmail(), actual.getEmail()); + + } + @Test public void testDeleteMembersOnOrganizationRemoval() { OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());