[Organizations] Allow orgs to define the redirect URL after user registers or accepts invitation link

Closes #33201

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
vramik 2024-09-27 11:07:21 +02:00 committed by Pedro Igor
parent d07d5ebf1a
commit c1653448f3
29 changed files with 297 additions and 33 deletions

View file

@ -32,6 +32,7 @@ public class OrganizationRepresentation {
private String alias;
private boolean enabled = true;
private String description;
private String redirectUrl;
private Map<String, List<String>> attributes;
private Set<OrganizationDomainRepresentation> domains;
private List<MemberRepresentation> 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<String, List<String>> getAttributes() {
return attributes;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View file

@ -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.

View file

@ -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

View file

@ -72,6 +72,11 @@ export const OrganizationForm = ({
addButtonLabel="addDomain"
/>
</FormGroup>
<TextControl
label={t("redirectUrl")}
name="redirectUrl"
labelIcon={t("organizationRedirectUrlHelp")}
/>
<TextAreaControl name="description" label={t("description")} />
</>
);

View file

@ -4,6 +4,7 @@ export default interface OrganizationRepresentation {
id?: string;
name?: string;
description?: string;
redirectUrl?: string;
enabled?: boolean;
attributes?: Record<string, string[]>;
domains?: OrganizationDomainRepresentation[];

View file

@ -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<OrganizationModel, MultivaluedHashMap<String, String>> attributes;
private final Set<OrganizationDomainModel> 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;
}

View file

@ -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<String, List<String>> getAttributes() {
if (isUpdated()) return updated.getAttributes();

View file

@ -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;
}

View file

@ -126,6 +126,16 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel<Or
entity.setDescription(description);
}
@Override
public String getRedirectUrl() {
return entity.getRedirectUrl();
}
@Override
public void setRedirectUrl(String redirectUrl) {
entity.setRedirectUrl(redirectUrl);
}
@Override
public void setAttributes(Map<String, List<String>> attributes) {
if (attributes == null) {

View file

@ -107,4 +107,10 @@
<dropTable tableName="USER_SESSION"/>
</changeSet>
<changeSet author="keycloak" id="26.0.0-33201-org-redirect-url">
<addColumn tableName="ORG">
<column name="REDIRECT_URL" type="VARCHAR(2048)"/>
</addColumn>
</changeSet>
</databaseChangeLog>

View file

@ -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;

View file

@ -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);

View file

@ -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);

View file

@ -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()

View file

@ -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);
}
}
}

View file

@ -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";
@ -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;

View file

@ -65,6 +65,10 @@ public interface OrganizationModel {
void setDescription(String description);
String getRedirectUrl();
void setRedirectUrl(String redirectUrl);
Map<String, List<String>> getAttributes();
void setAttributes(Map<String, List<String>> attributes);

View file

@ -143,7 +143,7 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler<Invi
// if we made it this far then go ahead and add the user to the organization
orgProvider.addMember(orgProvider.getById(token.getOrgId()), user);
String redirectUri = RedirectUtils.verifyRedirectUri(tokenContext.getSession(), token.getRedirectUri(), authSession.getClient());
String redirectUri = token.getRedirectUri();
if (redirectUri != null) {
authSession.setAuthNote(AuthenticationManager.SET_REDIRECT_URI_AFTER_REQUIRED_ACTIONS, "true");

View file

@ -145,8 +145,11 @@ public class OrganizationInvitationResource {
token.setOrgId(organization.getId());
String redirectUri = Urls.accountBase(session.getContext().getUri().getBaseUri()).path("/").build(realm.getName()).toString();
token.setRedirectUri(redirectUri);
if (organization.getRedirectUrl() == null || organization.getRedirectUrl().isBlank()) {
token.setRedirectUri(Urls.accountBase(session.getContext().getUri().getBaseUri()).path("/").build(realm.getName()).toString());
} else {
token.setRedirectUri(organization.getRedirectUrl());
}
return token.serialize(session, realm, session.getContext().getUri());
}

View file

@ -39,6 +39,8 @@ import org.keycloak.models.OrganizationModel;
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.organization.validation.OrganizationsValidation.OrganizationValidationException;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.resources.KeycloakOpenAPI;
@ -93,11 +95,12 @@ public class OrganizationResource {
@Operation(summary = "Updates the organization")
public Response update(OrganizationRepresentation organizationRep) {
try {
OrganizationsValidation.validateUrl(organizationRep.getRedirectUrl());
RepresentationToModel.toModel(organizationRep, organization);
adminEvent.operation(OperationType.UPDATE).resourcePath(session.getContext().getUri()).representation(organizationRep).success();
return Response.noContent().build();
} catch (ModelValidationException mve) {
throw ErrorResponse.error(mve.getMessage(), Response.Status.BAD_REQUEST);
} catch (ModelValidationException | OrganizationValidationException ex) {
throw ErrorResponse.error(ex.getMessage(), Response.Status.BAD_REQUEST);
}
}

View file

@ -49,6 +49,8 @@ import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.models.utils.RepresentationToModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.organization.validation.OrganizationsValidation;
import org.keycloak.organization.validation.OrganizationsValidation.OrganizationValidationException;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.resources.KeycloakOpenAPI;
@ -102,15 +104,17 @@ public class OrganizationsResource {
ReservedCharValidator.validateNoSpace(organization.getAlias());
try {
OrganizationsValidation.validateUrl(organization.getRedirectUrl());
OrganizationModel model = provider.create(organization.getName(), organization.getAlias());
RepresentationToModel.toModel(organization, model);
organization.setId(model.getId());
adminEvent.operation(OperationType.CREATE).resourcePath(session.getContext().getUri(), model.getId()).representation(organization).success();
return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(model.getId()).build()).build();
} catch (ModelValidationException mve) {
throw ErrorResponse.error(mve.getMessage(), Response.Status.BAD_REQUEST);
} catch (ModelDuplicateException mve) {
throw ErrorResponse.error(mve.getMessage(), Status.CONFLICT);
} catch (ModelValidationException | OrganizationValidationException ex) {
throw ErrorResponse.error(ex.getMessage(), Response.Status.BAD_REQUEST);
} catch (ModelDuplicateException mde) {
throw ErrorResponse.error(mde.getMessage(), Status.CONFLICT);
}
}

View file

@ -17,6 +17,7 @@
package org.keycloak.testsuite.pages;
import org.keycloak.testsuite.util.OAuthClient;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
@ -33,12 +34,12 @@ public class AppPage extends AbstractPage {
@Override
public void open() {
driver.navigate().to(oauth.APP_AUTH_ROOT);
driver.navigate().to(OAuthClient.APP_AUTH_ROOT);
}
@Override
public boolean isCurrent() {
return removeDefaultPorts(driver.getCurrentUrl()).startsWith(oauth.APP_AUTH_ROOT);
return removeDefaultPorts(driver.getCurrentUrl()).startsWith(OAuthClient.APP_AUTH_ROOT);
}
public RequestType getRequestType() {

View file

@ -0,0 +1,32 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.updaters;
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.representations.idm.OrganizationRepresentation;
public class OrganizationAttributeUpdater extends ServerResourceUpdater<OrganizationAttributeUpdater, OrganizationResource, OrganizationRepresentation> {
public OrganizationAttributeUpdater(OrganizationResource resource) {
super(resource, resource::toRepresentation, resource::update);
}
public OrganizationAttributeUpdater setRedirectUrl(String redirectUrl) {
this.rep.setRedirectUrl(redirectUrl);
return this;
}
}

View file

@ -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<String, String> 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<UserRepresentation> 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());
}

View file

@ -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());
}
}
}

View file

@ -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<OrganizationRepresentation> 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) {