[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 String alias;
private boolean enabled = true; private boolean enabled = true;
private String description; private String description;
private String redirectUrl;
private Map<String, List<String>> attributes; private Map<String, List<String>> attributes;
private Set<OrganizationDomainRepresentation> domains; private Set<OrganizationDomainRepresentation> domains;
private List<MemberRepresentation> members; private List<MemberRepresentation> members;
@ -77,6 +78,14 @@ public class OrganizationRepresentation {
this.description = description; this.description = description;
} }
public String getRedirectUrl() {
return redirectUrl;
}
public void setRedirectUrl(String redirectUrl) {
this.redirectUrl = redirectUrl;
}
public Map<String, List<String>> getAttributes() { public Map<String, List<String>> getAttributes() {
return attributes; 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. A user-friendly name for the organization. The name is unique within a realm.
Alias:: 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. 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:: Domains::
A set of one or more domains that belongs to this organization. A domain cannot be shared by different organizations within a realm. 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. 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 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. 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? disableConfirmOrganizationTitle=Disable organization?
disableConfirmOrganization=Are you sure you want to disable this organization? disableConfirmOrganization=Are you sure you want to disable this organization?
memberList=Member list memberList=Member list
@ -3259,4 +3261,4 @@ eventTypes.REMOVE_CREDENTIAL_ERROR.description=Remove credential error
groupDuplicated=Group duplicated groupDuplicated=Group duplicated
duplicateAGroup=Duplicate group duplicateAGroup=Duplicate group
couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}} 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. 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.

View file

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

View file

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

View file

@ -36,6 +36,7 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm {
private final String name; private final String name;
private final String alias; private final String alias;
private final String description; private final String description;
private final String redirectUrl;
private final boolean enabled; private final boolean enabled;
private final LazyLoader<OrganizationModel, MultivaluedHashMap<String, String>> attributes; private final LazyLoader<OrganizationModel, MultivaluedHashMap<String, String>> attributes;
private final Set<OrganizationDomainModel> domains; private final Set<OrganizationDomainModel> domains;
@ -47,6 +48,7 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm {
this.name = organization.getName(); this.name = organization.getName();
this.alias = organization.getAlias(); this.alias = organization.getAlias();
this.description = organization.getDescription(); this.description = organization.getDescription();
this.redirectUrl = organization.getRedirectUrl();
this.enabled = organization.isEnabled(); this.enabled = organization.isEnabled();
this.attributes = new DefaultLazyLoader<>(orgModel -> new MultivaluedHashMap<>(orgModel.getAttributes()), MultivaluedHashMap::new); this.attributes = new DefaultLazyLoader<>(orgModel -> new MultivaluedHashMap<>(orgModel.getAttributes()), MultivaluedHashMap::new);
this.domains = organization.getDomains().collect(Collectors.toSet()); this.domains = organization.getDomains().collect(Collectors.toSet());
@ -70,6 +72,10 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm {
return description; return description;
} }
public String getRedirectUrl() {
return redirectUrl;
}
public boolean isEnabled() { public boolean isEnabled() {
return enabled; return enabled;
} }

View file

@ -121,6 +121,18 @@ public class OrganizationAdapter implements OrganizationModel {
updated.setDescription(description); 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 @Override
public Map<String, List<String>> getAttributes() { public Map<String, List<String>> getAttributes() {
if (isUpdated()) return updated.getAttributes(); if (isUpdated()) return updated.getAttributes();

View file

@ -31,6 +31,7 @@ import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery; import jakarta.persistence.NamedQuery;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.keycloak.utils.StringUtil;
@Table(name="ORG") @Table(name="ORG")
@Entity @Entity
@ -66,6 +67,9 @@ public class OrganizationEntity {
@Column(name = "DESCRIPTION") @Column(name = "DESCRIPTION")
private String description; private String description;
@Column(name = "REDIRECT_URL")
private String redirectUrl;
@Column(name = "REALM_ID") @Column(name = "REALM_ID")
private String realmId; private String realmId;
@ -111,6 +115,17 @@ public class OrganizationEntity {
this.description = description; 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() { public String getRealmId() {
return realmId; return realmId;
} }

View file

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

View file

@ -107,4 +107,10 @@
<dropTable tableName="USER_SESSION"/> <dropTable tableName="USER_SESSION"/>
</changeSet> </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> </databaseChangeLog>

View file

@ -31,10 +31,8 @@ import org.keycloak.exportimport.ExportOptions;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.GroupModel.Type; import org.keycloak.models.GroupModel.Type;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleContainerModel; import org.keycloak.models.RoleContainerModel;
import org.keycloak.models.RoleModel; 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.CredentialRepresentation;
import org.keycloak.representations.idm.FederatedIdentityRepresentation; import org.keycloak.representations.idm.FederatedIdentityRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation; 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.ModelToRepresentation;
import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.models.utils.RepresentationToModel;
import org.keycloak.organization.OrganizationProvider; import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.validation.OrganizationsValidation;
import org.keycloak.partialimport.PartialImportResults; import org.keycloak.partialimport.PartialImportResults;
import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.representations.idm.ApplicationRepresentation; 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.GroupRepresentation;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation; 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.OAuthClientRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.PartialImportRepresentation; 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.importGroup;
import static org.keycloak.models.utils.RepresentationToModel.importRoles; import static org.keycloak.models.utils.RepresentationToModel.importRoles;
import static org.keycloak.models.utils.StripSecretsUtils.stripSecrets; 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. * 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); OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
for (OrganizationRepresentation orgRep : Optional.ofNullable(rep.getOrganizations()).orElse(Collections.emptyList())) { for (OrganizationRepresentation orgRep : Optional.ofNullable(rep.getOrganizations()).orElse(Collections.emptyList())) {
OrganizationsValidation.validateUrl(orgRep.getRedirectUrl());
OrganizationModel orgModel = provider.create(orgRep.getId(), orgRep.getName(), orgRep.getAlias()); OrganizationModel orgModel = provider.create(orgRep.getId(), orgRep.getName(), orgRep.getAlias());
RepresentationToModel.toModel(orgRep, orgModel); RepresentationToModel.toModel(orgRep, orgModel);

View file

@ -1310,6 +1310,7 @@ public class ModelToRepresentation {
rep.setName(model.getName()); rep.setName(model.getName());
rep.setAlias(model.getAlias()); rep.setAlias(model.getAlias());
rep.setEnabled(model.isEnabled()); rep.setEnabled(model.isEnabled());
rep.setRedirectUrl(model.getRedirectUrl());
rep.setDescription(model.getDescription()); rep.setDescription(model.getDescription());
model.getDomains().filter(Objects::nonNull).map(ModelToRepresentation::toRepresentation) model.getDomains().filter(Objects::nonNull).map(ModelToRepresentation::toRepresentation)
.forEach(rep::addDomain); .forEach(rep::addDomain);

View file

@ -1686,6 +1686,7 @@ public class RepresentationToModel {
model.setName(rep.getName()); model.setName(rep.getName());
model.setAlias(rep.getAlias()); model.setAlias(rep.getAlias());
model.setEnabled(rep.isEnabled()); model.setEnabled(rep.isEnabled());
model.setRedirectUrl(rep.getRedirectUrl());
model.setDescription(rep.getDescription()); model.setDescription(rep.getDescription());
model.setAttributes(rep.getAttributes()); model.setAttributes(rep.getAttributes());
model.setDomains(ofNullable(rep.getDomains()).orElse(Set.of()).stream() 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_SCHEME = "error-invalid-uri-scheme";
public static final String MESSAGE_INVALID_FRAGMENT = "error-invalid-uri-fragment"; 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"; public static final String ID = "uri";
@ -102,8 +102,8 @@ public class UriValidator extends AbstractSimpleValidator implements ConfiguredP
@Override @Override
protected void doValidate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) { protected void doValidate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
if(input == null || (input instanceof String && ((String) input).isEmpty())) { if (input == null || (input instanceof String && ((String) input).isEmpty())) {
return; return;
} }
try { try {
@ -125,13 +125,12 @@ public class UriValidator extends AbstractSimpleValidator implements ConfiguredP
private URI toUri(Object input) throws URISyntaxException { private URI toUri(Object input) throws URISyntaxException {
if (input instanceof String) { if (input instanceof String uriString) {
String uriString = (String) input;
return new URI(uriString); return new URI(uriString);
} else if (input instanceof URI) { } else if (input instanceof URI uri) {
return (URI) input; return uri;
} else if (input instanceof URL) { } else if (input instanceof URL url) {
return ((URL) input).toURI(); return url.toURI();
} }
return null; return null;

View file

@ -65,6 +65,10 @@ public interface OrganizationModel {
void setDescription(String description); void setDescription(String description);
String getRedirectUrl();
void setRedirectUrl(String redirectUrl);
Map<String, List<String>> getAttributes(); Map<String, List<String>> getAttributes();
void setAttributes(Map<String, List<String>> attributes); 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 // if we made it this far then go ahead and add the user to the organization
orgProvider.addMember(orgProvider.getById(token.getOrgId()), user); orgProvider.addMember(orgProvider.getById(token.getOrgId()), user);
String redirectUri = RedirectUtils.verifyRedirectUri(tokenContext.getSession(), token.getRedirectUri(), authSession.getClient()); String redirectUri = token.getRedirectUri();
if (redirectUri != null) { if (redirectUri != null) {
authSession.setAuthNote(AuthenticationManager.SET_REDIRECT_URI_AFTER_REQUIRED_ACTIONS, "true"); authSession.setAuthNote(AuthenticationManager.SET_REDIRECT_URI_AFTER_REQUIRED_ACTIONS, "true");

View file

@ -145,8 +145,11 @@ public class OrganizationInvitationResource {
token.setOrgId(organization.getId()); token.setOrgId(organization.getId());
String redirectUri = Urls.accountBase(session.getContext().getUri().getBaseUri()).path("/").build(realm.getName()).toString(); if (organization.getRedirectUrl() == null || organization.getRedirectUrl().isBlank()) {
token.setRedirectUri(redirectUri); 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()); 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.ModelToRepresentation;
import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.models.utils.RepresentationToModel;
import org.keycloak.organization.OrganizationProvider; 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.representations.idm.OrganizationRepresentation;
import org.keycloak.services.ErrorResponse; import org.keycloak.services.ErrorResponse;
import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.KeycloakOpenAPI;
@ -93,11 +95,12 @@ public class OrganizationResource {
@Operation(summary = "Updates the organization") @Operation(summary = "Updates the organization")
public Response update(OrganizationRepresentation organizationRep) { public Response update(OrganizationRepresentation organizationRep) {
try { try {
OrganizationsValidation.validateUrl(organizationRep.getRedirectUrl());
RepresentationToModel.toModel(organizationRep, organization); RepresentationToModel.toModel(organizationRep, organization);
adminEvent.operation(OperationType.UPDATE).resourcePath(session.getContext().getUri()).representation(organizationRep).success(); adminEvent.operation(OperationType.UPDATE).resourcePath(session.getContext().getUri()).representation(organizationRep).success();
return Response.noContent().build(); return Response.noContent().build();
} catch (ModelValidationException mve) { } catch (ModelValidationException | OrganizationValidationException ex) {
throw ErrorResponse.error(mve.getMessage(), Response.Status.BAD_REQUEST); 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.models.utils.RepresentationToModel;
import org.keycloak.organization.OrganizationProvider; import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations; 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.representations.idm.OrganizationRepresentation;
import org.keycloak.services.ErrorResponse; import org.keycloak.services.ErrorResponse;
import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.KeycloakOpenAPI;
@ -102,15 +104,17 @@ public class OrganizationsResource {
ReservedCharValidator.validateNoSpace(organization.getAlias()); ReservedCharValidator.validateNoSpace(organization.getAlias());
try { try {
OrganizationsValidation.validateUrl(organization.getRedirectUrl());
OrganizationModel model = provider.create(organization.getName(), organization.getAlias()); OrganizationModel model = provider.create(organization.getName(), organization.getAlias());
RepresentationToModel.toModel(organization, model); RepresentationToModel.toModel(organization, model);
organization.setId(model.getId()); organization.setId(model.getId());
adminEvent.operation(OperationType.CREATE).resourcePath(session.getContext().getUri(), model.getId()).representation(organization).success(); 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(); return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(model.getId()).build()).build();
} catch (ModelValidationException mve) { } catch (ModelValidationException | OrganizationValidationException ex) {
throw ErrorResponse.error(mve.getMessage(), Response.Status.BAD_REQUEST); throw ErrorResponse.error(ex.getMessage(), Response.Status.BAD_REQUEST);
} catch (ModelDuplicateException mve) { } catch (ModelDuplicateException mde) {
throw ErrorResponse.error(mve.getMessage(), Status.CONFLICT); throw ErrorResponse.error(mde.getMessage(), Status.CONFLICT);
} }
} }

View file

@ -17,6 +17,7 @@
package org.keycloak.testsuite.pages; package org.keycloak.testsuite.pages;
import org.keycloak.testsuite.util.OAuthClient;
import org.openqa.selenium.WebElement; import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy; import org.openqa.selenium.support.FindBy;
@ -33,12 +34,12 @@ public class AppPage extends AbstractPage {
@Override @Override
public void open() { public void open() {
driver.navigate().to(oauth.APP_AUTH_ROOT); driver.navigate().to(OAuthClient.APP_AUTH_ROOT);
} }
@Override @Override
public boolean isCurrent() { public boolean isCurrent() {
return removeDefaultPorts(driver.getCurrentUrl()).startsWith(oauth.APP_AUTH_ROOT); return removeDefaultPorts(driver.getCurrentUrl()).startsWith(OAuthClient.APP_AUTH_ROOT);
} }
public RequestType getRequestType() { 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 java.time.Duration;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.resource.OrganizationResource; 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.admin.ApiUtil;
import org.keycloak.testsuite.pages.InfoPage; import org.keycloak.testsuite.pages.InfoPage;
import org.keycloak.testsuite.pages.RegisterPage; import org.keycloak.testsuite.pages.RegisterPage;
import org.keycloak.testsuite.updaters.OrganizationAttributeUpdater;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.GreenMailRule; import org.keycloak.testsuite.util.GreenMailRule;
import org.keycloak.testsuite.util.MailUtils; import org.keycloak.testsuite.util.MailUtils;
import org.keycloak.testsuite.util.MailUtils.EmailBody; import org.keycloak.testsuite.util.MailUtils.EmailBody;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.UserBuilder;
public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
@ -69,6 +72,11 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
@Page @Page
protected RegisterPage registerPage; protected RegisterPage registerPage;
@Before
public void setDriverTimeout() {
driver.manage().timeouts().pageLoadTimeout(Duration.ofMinutes(1));
}
@Override @Override
public void configureTestRealm(RealmRepresentation testRealm) { public void configureTestRealm(RealmRepresentation testRealm) {
Map<String, String> smtpConfig = testRealm.getSmtpServer(); Map<String, String> smtpConfig = testRealm.getSmtpServer();
@ -87,6 +95,22 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
acceptInvitation(organization, user); 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 @Test
public void testInviteExistingUserWithEmail() throws IOException, MessagingException { public void testInviteExistingUserWithEmail() throws IOException, MessagingException {
UserRepresentation user = createUser("invitedWithMatchingEmail", "invitedWithMatchingEmail@myemail.com"); UserRepresentation user = createUser("invitedWithMatchingEmail", "invitedWithMatchingEmail@myemail.com");
@ -98,6 +122,22 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
acceptInvitation(organization, user); 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 @Test
public void testInviteNewUserRegistration() throws IOException, MessagingException { public void testInviteNewUserRegistration() throws IOException, MessagingException {
String email = "inviteduser@email"; String email = "inviteduser@email";
@ -122,6 +162,34 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertNotNull(driver.manage().getCookieNamed(CookieType.IDENTITY.getName())); 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 @Test
public void testRegistrationEnabledWhenInvitingNewUser() throws Exception { public void testRegistrationEnabledWhenInvitingNewUser() throws Exception {
String email = "inviteduser@email"; String email = "inviteduser@email";
@ -168,6 +236,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
try (Response response = testRealm().users().create(user)) { try (Response response = testRealm().users().create(user)) {
user.setId(ApiUtil.getCreatedId(response)); user.setId(ApiUtil.getCreatedId(response));
} }
getCleanup().addUserId(user.getId());
return user; return user;
} }
@ -224,13 +293,16 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
driver.navigate().to(link); driver.navigate().to(link);
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> email.equals(actual.getEmail()))); Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> email.equals(actual.getEmail())));
registerPage.assertCurrent(organizationName); registerPage.assertCurrent(organizationName);
driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(10));
assertThat(registerPage.getEmail(), equalTo(expectedEmail)); assertThat(registerPage.getEmail(), equalTo(expectedEmail));
registerPage.register("firstName", "lastName", email, registerPage.register("firstName", "lastName", email,
"invitedUser", "password", "password", null, false, null); "invitedUser", "password", "password", null, false, null);
} }
private void acceptInvitation(OrganizationResource organization, UserRepresentation user) throws MessagingException, IOException { 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()); String link = getInvitationLinkFromEmail(user.getFirstName(), user.getLastName());
driver.navigate().to(link); driver.navigate().to(link);
// not yet a member // 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(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:")); assertThat(infoPage.getInfo(), containsString("By clicking on the link below, you will become a member of the " + organizationName + " organization:"));
infoPage.clickToContinue(); infoPage.clickToContinue();
// redirect to the account console and eventually force the user to authenticate if not already // redirect to the redirectUrl and eventually force the user to authenticate if not already
assertThat(driver.getTitle(), containsString("Account Management")); assertThat(driver.getTitle(), containsString(pageTitle));
// now a member // now a member
Assert.assertNotNull(organization.members().member(user.getId()).toRepresentation()); 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()); 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.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.hasSize;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; 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.exportimport.singlefile.SingleFileImportProviderFactory;
import org.keycloak.models.OrganizationModel; import org.keycloak.models.OrganizationModel;
import org.keycloak.models.utils.DefaultAuthenticationFlows; import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation; import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation;
@ -75,6 +75,11 @@ public class OrganizationExportTest extends AbstractOrganizationTest {
OrganizationRepresentation orgRep = createOrganization(testRealm(), getCleanup(), "org-" + i, broker, domain); OrganizationRepresentation orgRep = createOrganization(testRealm(), getCleanup(), "org-" + i, broker, domain);
OrganizationResource organization = testRealm().organizations().get(orgRep.getId()); 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); expectedOrganizations.add(orgRep);
for (int j = 0; j < 3; j++) { for (int j = 0; j < 3; j++) {
@ -114,7 +119,7 @@ public class OrganizationExportTest extends AbstractOrganizationTest {
List<OrganizationRepresentation> organizations = testRealm().organizations().getAll(); List<OrganizationRepresentation> organizations = testRealm().organizations().getAll();
assertEquals(expectedOrganizations.size(), organizations.size()); 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(), assertThat(organizations.stream().map(OrganizationRepresentation::getId).toList(),
Matchers.containsInAnyOrder(expectedOrganizations.stream().map(OrganizationRepresentation::getId).toArray())); Matchers.containsInAnyOrder(expectedOrganizations.stream().map(OrganizationRepresentation::getId).toArray()));
assertThat(organizations.stream().map(OrganizationRepresentation::getName).toList(), assertThat(organizations.stream().map(OrganizationRepresentation::getName).toList(),
@ -123,6 +128,8 @@ public class OrganizationExportTest extends AbstractOrganizationTest {
Matchers.containsInAnyOrder(expectedOrganizations.stream().map(OrganizationRepresentation::getAlias).toArray())); Matchers.containsInAnyOrder(expectedOrganizations.stream().map(OrganizationRepresentation::getAlias).toArray()));
assertThat(organizations.stream().map(OrganizationRepresentation::getDescription).toList(), assertThat(organizations.stream().map(OrganizationRepresentation::getDescription).toList(),
Matchers.containsInAnyOrder(expectedOrganizations.stream().map(OrganizationRepresentation::getDescription).toArray())); 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. // the endpoint search method returns brief representations of orgs - to get full rep we need to fetch by id.
for (OrganizationRepresentation organization : organizations) { for (OrganizationRepresentation organization : organizations) {