diff --git a/core/src/main/java/org/keycloak/representations/idm/OrganizationRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/OrganizationRepresentation.java index 5d0cf292e5..0c22cd5029 100644 --- a/core/src/main/java/org/keycloak/representations/idm/OrganizationRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/OrganizationRepresentation.java @@ -32,6 +32,7 @@ public class OrganizationRepresentation { private String alias; private boolean enabled = true; private String description; + private String redirectUrl; private Map> attributes; private Set domains; private List members; @@ -77,6 +78,14 @@ public class OrganizationRepresentation { this.description = description; } + public String getRedirectUrl() { + return redirectUrl; + } + + public void setRedirectUrl(String redirectUrl) { + this.redirectUrl = redirectUrl; + } + public Map> getAttributes() { return attributes; } diff --git a/docs/documentation/server_admin/images/organizations-create-org.png b/docs/documentation/server_admin/images/organizations-create-org.png index 53be71c9a9..e5909eabe2 100644 Binary files a/docs/documentation/server_admin/images/organizations-create-org.png and b/docs/documentation/server_admin/images/organizations-create-org.png differ diff --git a/docs/documentation/server_admin/images/organizations-delete-org.png b/docs/documentation/server_admin/images/organizations-delete-org.png index 45ad708bb9..436b00aaf8 100644 Binary files a/docs/documentation/server_admin/images/organizations-delete-org.png and b/docs/documentation/server_admin/images/organizations-delete-org.png differ diff --git a/docs/documentation/server_admin/images/organizations-disable-org.png b/docs/documentation/server_admin/images/organizations-disable-org.png index ce3f688249..aa817a3d8e 100644 Binary files a/docs/documentation/server_admin/images/organizations-disable-org.png and b/docs/documentation/server_admin/images/organizations-disable-org.png differ diff --git a/docs/documentation/server_admin/topics/organizations/managing-organization.adoc b/docs/documentation/server_admin/topics/organizations/managing-organization.adoc index 3bbcd90931..ec9fa6f80a 100644 --- a/docs/documentation/server_admin/topics/organizations/managing-organization.adoc +++ b/docs/documentation/server_admin/topics/organizations/managing-organization.adoc @@ -41,9 +41,11 @@ Name:: A user-friendly name for the organization. The name is unique within a realm. Alias:: - An alias for this organization, used to reference the organization internally. The alias is unique within a realm and must be URL-friendly, so characters not usually allowed in URLs will not be allowed in the alias. If not set, {project_name} will attempt to use the name as the alias. If the name is not URL-friendly, you will get an error and will be asked to specify an alias. Once defined, the alias cannot be changed afterwards. +Redirect URL:: +After completing registration or accepting an invitation to the organization sent via email, the user is automatically redirected to the specified redirect url. If left empty, the user will be redirected to the account console by default. + Domains:: A set of one or more domains that belongs to this organization. A domain cannot be shared by different organizations within a realm. diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 939805255e..21394b6214 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3175,6 +3175,8 @@ domain=Domain organizationDomainHelp=A set of one or more internet domains associated with the organization. The domain is used to map users to an organization based on their email domain and to authenticate them accordingly in the scope of the organization. addDomain=Add domain organizationAliasHelp=The alias uniquely identifies an organization using a format that is mainly targeted for referencing the organization internally. For instance, when issuing organization-related claims into tokens or when in a custom theme. +organizationRedirectUrlHelp=Automatically redirect the user after completing registration or accepting an invitation to the organization. If left empty, the user will be redirected to the account console by default. +redirectUrl=Redirect URL disableConfirmOrganizationTitle=Disable organization? disableConfirmOrganization=Are you sure you want to disable this organization? memberList=Member list @@ -3259,4 +3261,4 @@ eventTypes.REMOVE_CREDENTIAL_ERROR.description=Remove credential error groupDuplicated=Group duplicated duplicateAGroup=Duplicate group couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}} -duplicateGroupWarning=Duplication of groups with a large number of subgroups is not supported. Please ensure that the group you are duplicating does not have a large number of subgroups. \ No newline at end of file +duplicateGroupWarning=Duplication of groups with a large number of subgroups is not supported. Please ensure that the group you are duplicating does not have a large number of subgroups. diff --git a/js/apps/admin-ui/src/organizations/OrganizationForm.tsx b/js/apps/admin-ui/src/organizations/OrganizationForm.tsx index ea19f2a67e..2aca97fc82 100644 --- a/js/apps/admin-ui/src/organizations/OrganizationForm.tsx +++ b/js/apps/admin-ui/src/organizations/OrganizationForm.tsx @@ -72,6 +72,11 @@ export const OrganizationForm = ({ addButtonLabel="addDomain" /> + ); diff --git a/js/libs/keycloak-admin-client/src/defs/organizationRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/organizationRepresentation.ts index eac1b6f879..331256b7fc 100644 --- a/js/libs/keycloak-admin-client/src/defs/organizationRepresentation.ts +++ b/js/libs/keycloak-admin-client/src/defs/organizationRepresentation.ts @@ -4,6 +4,7 @@ export default interface OrganizationRepresentation { id?: string; name?: string; description?: string; + redirectUrl?: string; enabled?: boolean; attributes?: Record; domains?: OrganizationDomainRepresentation[]; diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/CachedOrganization.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/CachedOrganization.java index 4a59326b69..52fb14bcba 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/CachedOrganization.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/CachedOrganization.java @@ -36,6 +36,7 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm { private final String name; private final String alias; private final String description; + private final String redirectUrl; private final boolean enabled; private final LazyLoader> attributes; private final Set domains; @@ -47,6 +48,7 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm { this.name = organization.getName(); this.alias = organization.getAlias(); this.description = organization.getDescription(); + this.redirectUrl = organization.getRedirectUrl(); this.enabled = organization.isEnabled(); this.attributes = new DefaultLazyLoader<>(orgModel -> new MultivaluedHashMap<>(orgModel.getAttributes()), MultivaluedHashMap::new); this.domains = organization.getDomains().collect(Collectors.toSet()); @@ -70,6 +72,10 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm { return description; } + public String getRedirectUrl() { + return redirectUrl; + } + public boolean isEnabled() { return enabled; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/OrganizationAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/OrganizationAdapter.java index aee743f0c8..14d4b9e6cc 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/OrganizationAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/organization/OrganizationAdapter.java @@ -121,6 +121,18 @@ public class OrganizationAdapter implements OrganizationModel { updated.setDescription(description); } + @Override + public String getRedirectUrl() { + if (isUpdated()) return updated.getRedirectUrl(); + return cached.getRedirectUrl(); + } + + @Override + public void setRedirectUrl(String redirectUrl) { + getDelegateForUpdate(); + updated.setRedirectUrl(redirectUrl); + } + @Override public Map> getAttributes() { if (isUpdated()) return updated.getAttributes(); 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 fb4b287011..4ffe1ec05a 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 @@ -31,6 +31,7 @@ import jakarta.persistence.NamedQueries; import jakarta.persistence.NamedQuery; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import org.keycloak.utils.StringUtil; @Table(name="ORG") @Entity @@ -66,6 +67,9 @@ public class OrganizationEntity { @Column(name = "DESCRIPTION") private String description; + @Column(name = "REDIRECT_URL") + private String redirectUrl; + @Column(name = "REALM_ID") private String realmId; @@ -111,6 +115,17 @@ public class OrganizationEntity { this.description = description; } + public String getRedirectUrl() { + return redirectUrl; + } + + public void setRedirectUrl(String redirectUrl) { + if (StringUtil.isNullOrEmpty(redirectUrl)) { + redirectUrl = null; + } + this.redirectUrl = redirectUrl; + } + public String getRealmId() { return realmId; } 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 10168b0b95..d150d2738d 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 @@ -126,6 +126,16 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel> attributes) { if (attributes == null) { diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.0.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.0.0.xml index 04b0683f05..c653caf8da 100644 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.0.0.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.0.0.xml @@ -107,4 +107,10 @@ + + + + + + diff --git a/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java b/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java index 07a6649d9e..161c721c57 100755 --- a/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java +++ b/model/storage-private/src/main/java/org/keycloak/exportimport/util/ExportUtils.java @@ -31,10 +31,8 @@ import org.keycloak.exportimport.ExportOptions; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.FederatedIdentityModel; -import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel.Type; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleContainerModel; import org.keycloak.models.RoleModel; @@ -46,7 +44,6 @@ import org.keycloak.representations.idm.ComponentExportRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; 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.RealmRepresentation; import org.keycloak.representations.idm.RoleRepresentation; diff --git a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java index c3c3485c0e..5da87057a0 100644 --- a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultExportImportManager.java @@ -66,6 +66,7 @@ import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.organization.OrganizationProvider; +import org.keycloak.organization.validation.OrganizationsValidation; import org.keycloak.partialimport.PartialImportResults; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.representations.idm.ApplicationRepresentation; @@ -83,6 +84,8 @@ import org.keycloak.representations.idm.FederatedIdentityRepresentation; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.MemberRepresentation; +import org.keycloak.representations.idm.MembershipType; import org.keycloak.representations.idm.OAuthClientRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.PartialImportRepresentation; @@ -132,8 +135,6 @@ import static org.keycloak.models.utils.RepresentationToModel.createRoleMappings import static org.keycloak.models.utils.RepresentationToModel.importGroup; import static org.keycloak.models.utils.RepresentationToModel.importRoles; import static org.keycloak.models.utils.StripSecretsUtils.stripSecrets; -import org.keycloak.representations.idm.MemberRepresentation; -import org.keycloak.representations.idm.MembershipType; /** * This wraps the functionality about export/import for the storage. @@ -1589,6 +1590,7 @@ public class DefaultExportImportManager implements ExportImportManager { OrganizationProvider provider = session.getProvider(OrganizationProvider.class); for (OrganizationRepresentation orgRep : Optional.ofNullable(rep.getOrganizations()).orElse(Collections.emptyList())) { + OrganizationsValidation.validateUrl(orgRep.getRedirectUrl()); OrganizationModel orgModel = provider.create(orgRep.getId(), orgRep.getName(), orgRep.getAlias()); RepresentationToModel.toModel(orgRep, orgModel); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index a9f5a994f7..bfd214107b 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -1310,6 +1310,7 @@ public class ModelToRepresentation { rep.setName(model.getName()); rep.setAlias(model.getAlias()); rep.setEnabled(model.isEnabled()); + rep.setRedirectUrl(model.getRedirectUrl()); rep.setDescription(model.getDescription()); model.getDomains().filter(Objects::nonNull).map(ModelToRepresentation::toRepresentation) .forEach(rep::addDomain); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 047c7e1bef..bb0a49ced9 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -1686,6 +1686,7 @@ public class RepresentationToModel { model.setName(rep.getName()); model.setAlias(rep.getAlias()); model.setEnabled(rep.isEnabled()); + model.setRedirectUrl(rep.getRedirectUrl()); model.setDescription(rep.getDescription()); model.setAttributes(rep.getAttributes()); model.setDomains(ofNullable(rep.getDomains()).orElse(Set.of()).stream() diff --git a/server-spi-private/src/main/java/org/keycloak/organization/validation/OrganizationsValidation.java b/server-spi-private/src/main/java/org/keycloak/organization/validation/OrganizationsValidation.java new file mode 100644 index 0000000000..3e57fa7daf --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/organization/validation/OrganizationsValidation.java @@ -0,0 +1,33 @@ +/* + * 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.validation; + +import org.keycloak.validate.BuiltinValidators; + +public class OrganizationsValidation { + public static void validateUrl(String redirectUrl) { + if (!BuiltinValidators.uriValidator().validate(redirectUrl).isValid()) { + throw new OrganizationValidationException("Organization redirect URL is not valid."); + } + } + + public static class OrganizationValidationException extends RuntimeException { + public OrganizationValidationException(String message) { + super(message); + } + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/validate/validators/UriValidator.java b/server-spi-private/src/main/java/org/keycloak/validate/validators/UriValidator.java index 37f554086b..f114ae85e8 100644 --- a/server-spi-private/src/main/java/org/keycloak/validate/validators/UriValidator.java +++ b/server-spi-private/src/main/java/org/keycloak/validate/validators/UriValidator.java @@ -54,9 +54,9 @@ public class UriValidator extends AbstractSimpleValidator implements ConfiguredP public static final String MESSAGE_INVALID_SCHEME = "error-invalid-uri-scheme"; public static final String MESSAGE_INVALID_FRAGMENT = "error-invalid-uri-fragment"; - public static boolean DEFAULT_ALLOW_FRAGMENT = true; + public static final boolean DEFAULT_ALLOW_FRAGMENT = true; - public static boolean DEFAULT_REQUIRE_VALID_URL = true; + public static final boolean DEFAULT_REQUIRE_VALID_URL = true; public static final String ID = "uri"; @@ -102,8 +102,8 @@ public class UriValidator extends AbstractSimpleValidator implements ConfiguredP @Override protected void doValidate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) { - if(input == null || (input instanceof String && ((String) input).isEmpty())) { - return; + if (input == null || (input instanceof String && ((String) input).isEmpty())) { + return; } try { @@ -125,13 +125,12 @@ public class UriValidator extends AbstractSimpleValidator implements ConfiguredP private URI toUri(Object input) throws URISyntaxException { - if (input instanceof String) { - String uriString = (String) input; + if (input instanceof String uriString) { return new URI(uriString); - } else if (input instanceof URI) { - return (URI) input; - } else if (input instanceof URL) { - return ((URL) input).toURI(); + } else if (input instanceof URI uri) { + return uri; + } else if (input instanceof URL url) { + return url.toURI(); } return null; 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 389830183c..1765c3e2c6 100644 --- a/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java +++ b/server-spi/src/main/java/org/keycloak/models/OrganizationModel.java @@ -65,6 +65,10 @@ public interface OrganizationModel { void setDescription(String description); + String getRedirectUrl(); + + void setRedirectUrl(String redirectUrl); + Map> getAttributes(); void setAttributes(Map> attributes); diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java index f4a9a353a6..da297146c7 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java @@ -143,7 +143,7 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler { + + public OrganizationAttributeUpdater(OrganizationResource resource) { + super(resource, resource::toRepresentation, resource::update); + } + + public OrganizationAttributeUpdater setRedirectUrl(String redirectUrl) { + this.rep.setRedirectUrl(redirectUrl); + return this; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java index 74d34bbcca..c7362b7f92 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java @@ -35,6 +35,7 @@ import jakarta.ws.rs.core.Response; import java.time.Duration; import org.hamcrest.Matchers; import org.jboss.arquillian.graphene.page.Page; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.keycloak.admin.client.resource.OrganizationResource; @@ -49,10 +50,12 @@ import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.pages.InfoPage; import org.keycloak.testsuite.pages.RegisterPage; +import org.keycloak.testsuite.updaters.OrganizationAttributeUpdater; import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.util.GreenMailRule; import org.keycloak.testsuite.util.MailUtils; import org.keycloak.testsuite.util.MailUtils.EmailBody; +import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.UserBuilder; public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { @@ -69,6 +72,11 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { @Page protected RegisterPage registerPage; + @Before + public void setDriverTimeout() { + driver.manage().timeouts().pageLoadTimeout(Duration.ofMinutes(1)); + } + @Override public void configureTestRealm(RealmRepresentation testRealm) { Map smtpConfig = testRealm.getSmtpServer(); @@ -87,6 +95,22 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { acceptInvitation(organization, user); } + @Test + public void testInviteExistingUserCustomRedirectUrl() throws IOException, MessagingException { + UserRepresentation user = createUser("invited", "invited@myemail.com"); + + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + + try ( + OrganizationAttributeUpdater oau = new OrganizationAttributeUpdater(organization).setRedirectUrl(OAuthClient.APP_AUTH_ROOT).update(); + Response response = organization.members().inviteExistingUser(user.getId()); + ) { + assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); + + acceptInvitation(organization, user, "AUTH_RESPONSE"); + } + } + @Test public void testInviteExistingUserWithEmail() throws IOException, MessagingException { UserRepresentation user = createUser("invitedWithMatchingEmail", "invitedWithMatchingEmail@myemail.com"); @@ -98,6 +122,22 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { acceptInvitation(organization, user); } + @Test + public void testInviteExistingUserWithEmailCustomRedirectUrl() throws IOException, MessagingException { + UserRepresentation user = createUser("invitedWithMatchingEmail", "invitedWithMatchingEmail@myemail.com"); + + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + + try ( + OrganizationAttributeUpdater oau = new OrganizationAttributeUpdater(organization).setRedirectUrl(OAuthClient.APP_AUTH_ROOT).update(); + Response response = organization.members().inviteUser(user.getEmail(), "Homer", "Simpson"); + ) { + assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); + + acceptInvitation(organization, user, "AUTH_RESPONSE"); + } + } + @Test public void testInviteNewUserRegistration() throws IOException, MessagingException { String email = "inviteduser@email"; @@ -122,6 +162,34 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { Assert.assertNotNull(driver.manage().getCookieNamed(CookieType.IDENTITY.getName())); } + @Test + public void testInviteNewUserRegistrationCustomRedirectUrl() throws IOException, MessagingException { + String email = "inviteduser@email"; + String firstName = "Homer"; + String lastName = "Simpson"; + + OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); + try ( + OrganizationAttributeUpdater oau = new OrganizationAttributeUpdater(organization).setRedirectUrl(OAuthClient.APP_AUTH_ROOT).update(); + Response response = organization.members().inviteUser(email, firstName, lastName); + ) { + assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); + + registerUser(organization, email); + + List users = testRealm().users().searchByEmail(email, true); + assertThat(users, Matchers.not(empty())); + // user is a member + MemberRepresentation member = organization.members().member(users.get(0).getId()).toRepresentation(); + Assert.assertNotNull(member); + assertThat(member.getMembershipType(), equalTo(MembershipType.MANAGED)); + getCleanup().addCleanup(() -> testRealm().users().get(users.get(0).getId()).remove()); + + // authenticated to the app + assertThat(driver.getTitle(), containsString("AUTH_RESPONSE")); + } + } + @Test public void testRegistrationEnabledWhenInvitingNewUser() throws Exception { String email = "inviteduser@email"; @@ -168,6 +236,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { try (Response response = testRealm().users().create(user)) { user.setId(ApiUtil.getCreatedId(response)); } + getCleanup().addUserId(user.getId()); return user; } @@ -224,13 +293,16 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { driver.navigate().to(link); Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> email.equals(actual.getEmail()))); registerPage.assertCurrent(organizationName); - driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(10)); assertThat(registerPage.getEmail(), equalTo(expectedEmail)); registerPage.register("firstName", "lastName", email, "invitedUser", "password", "password", null, false, null); } private void acceptInvitation(OrganizationResource organization, UserRepresentation user) throws MessagingException, IOException { + acceptInvitation(organization, user, "Account Management"); + } + + private void acceptInvitation(OrganizationResource organization, UserRepresentation user, String pageTitle) throws MessagingException, IOException { String link = getInvitationLinkFromEmail(user.getFirstName(), user.getLastName()); driver.navigate().to(link); // not yet a member @@ -239,8 +311,8 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { assertThat(driver.getPageSource(), containsString("You are about to join organization " + organizationName)); assertThat(infoPage.getInfo(), containsString("By clicking on the link below, you will become a member of the " + organizationName + " organization:")); infoPage.clickToContinue(); - // redirect to the account console and eventually force the user to authenticate if not already - assertThat(driver.getTitle(), containsString("Account Management")); + // redirect to the redirectUrl and eventually force the user to authenticate if not already + assertThat(driver.getTitle(), containsString(pageTitle)); // now a member Assert.assertNotNull(organization.members().member(user.getId()).toRepresentation()); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java index 6a8ed23c29..7e039fbb90 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationTest.java @@ -551,4 +551,41 @@ public class OrganizationTest extends AbstractOrganizationTest { assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus()); } } + + @Test + public void testInvalidRedirectUri() { + OrganizationRepresentation expected = createOrganization(); + expected.setRedirectUrl("http://valid.url:8080/"); + + OrganizationResource organization = testRealm().organizations().get(expected.getId()); + + try (Response response = organization.update(expected)) { + assertThat(response.getStatus(), equalTo(Status.NO_CONTENT.getStatusCode())); + assertThat(organization.toRepresentation().getRedirectUrl(), equalTo("http://valid.url:8080/")); + } + + expected.setRedirectUrl(""); + try (Response response = organization.update(expected)) { + assertThat(response.getStatus(), equalTo(Status.NO_CONTENT.getStatusCode())); + assertThat(organization.toRepresentation().getRedirectUrl(), nullValue()); + } + + expected.setRedirectUrl(" "); + try (Response response = organization.update(expected)) { + assertThat(response.getStatus(), equalTo(Status.BAD_REQUEST.getStatusCode())); + assertThat(organization.toRepresentation().getRedirectUrl(), nullValue()); + } + + expected.setRedirectUrl("invalid"); + try (Response response = organization.update(expected)) { + assertThat(response.getStatus(), equalTo(Status.BAD_REQUEST.getStatusCode())); + assertThat(organization.toRepresentation().getRedirectUrl(), nullValue()); + } + + expected.setRedirectUrl("https://\ninvalid"); + try (Response response = organization.update(expected)) { + assertThat(response.getStatus(), equalTo(Status.BAD_REQUEST.getStatusCode())); + assertThat(organization.toRepresentation().getRedirectUrl(), nullValue()); + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/exportimport/OrganizationExportTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/exportimport/OrganizationExportTest.java index 2d2e63ba24..2e92b040c0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/exportimport/OrganizationExportTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/exportimport/OrganizationExportTest.java @@ -22,6 +22,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -46,7 +47,6 @@ import org.keycloak.exportimport.singlefile.SingleFileExportProviderFactory; import org.keycloak.exportimport.singlefile.SingleFileImportProviderFactory; import org.keycloak.models.OrganizationModel; import org.keycloak.models.utils.DefaultAuthenticationFlows; -import org.keycloak.organization.OrganizationProvider; import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; @@ -75,6 +75,11 @@ public class OrganizationExportTest extends AbstractOrganizationTest { OrganizationRepresentation orgRep = createOrganization(testRealm(), getCleanup(), "org-" + i, broker, domain); OrganizationResource organization = testRealm().organizations().get(orgRep.getId()); + orgRep.setRedirectUrl("https://0.0.0.0:8080"); + try (Response response = organization.update(orgRep)) { + assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode())); + } + expectedOrganizations.add(orgRep); for (int j = 0; j < 3; j++) { @@ -114,7 +119,7 @@ public class OrganizationExportTest extends AbstractOrganizationTest { List organizations = testRealm().organizations().getAll(); assertEquals(expectedOrganizations.size(), organizations.size()); - // id, name, alias, and description should have all been preserved. + // id, name, alias, description and redirectUrl should have all been preserved. assertThat(organizations.stream().map(OrganizationRepresentation::getId).toList(), Matchers.containsInAnyOrder(expectedOrganizations.stream().map(OrganizationRepresentation::getId).toArray())); assertThat(organizations.stream().map(OrganizationRepresentation::getName).toList(), @@ -123,6 +128,8 @@ public class OrganizationExportTest extends AbstractOrganizationTest { Matchers.containsInAnyOrder(expectedOrganizations.stream().map(OrganizationRepresentation::getAlias).toArray())); assertThat(organizations.stream().map(OrganizationRepresentation::getDescription).toList(), Matchers.containsInAnyOrder(expectedOrganizations.stream().map(OrganizationRepresentation::getDescription).toArray())); + assertThat(organizations.stream().map(OrganizationRepresentation::getRedirectUrl).toList(), + Matchers.containsInAnyOrder(expectedOrganizations.stream().map(OrganizationRepresentation::getRedirectUrl).toArray())); // the endpoint search method returns brief representations of orgs - to get full rep we need to fetch by id. for (OrganizationRepresentation organization : organizations) {