[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:
parent
d07d5ebf1a
commit
c1653448f3
29 changed files with 297 additions and 33 deletions
|
@ -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 |
|
@ -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.
|
||||
|
||||
|
|
|
@ -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.
|
||||
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.
|
||||
|
|
|
@ -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")} />
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -4,6 +4,7 @@ export default interface OrganizationRepresentation {
|
|||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
redirectUrl?: string;
|
||||
enabled?: boolean;
|
||||
attributes?: Record<string, string[]>;
|
||||
domains?: OrganizationDomainRepresentation[];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue