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 5d0cf292e53..0c22cd50290 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 53be71c9a97..e5909eabe27 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 45ad708bb99..436b00aaf84 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 ce3f6882495..aa817a3d8ef 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 3bbcd909316..ec9fa6f80a6 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 939805255e8..21394b62147 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 ea19f2a67ee..2aca97fc823 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 eac1b6f8792..331256b7fcc 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 4a59326b69e..52fb14bcba5 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 aee743f0c8e..14d4b9e6cc1 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 fb4b2870115..4ffe1ec05a3 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 10168b0b958..d150d2738d1 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 04b0683f054..c653caf8da6 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 07a6649d9ed..161c721c57c 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 c3c3485c0e1..5da87057a09 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 a9f5a994f72..bfd214107b0 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 047c7e1beff..bb0a49ced9e 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 00000000000..3e57fa7daf3 --- /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 37f554086ba..f114ae85e80 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 389830183c7..1765c3e2c6a 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 f4a9a353a66..da297146c7a 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 74d34bbcca1..c7362b7f925 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 6a8ed23c29c..7e039fbb90f 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 2d2e63ba242..2e92b040c05 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) {