Adding an alias to organization and exposing them to templates

Closes #30312
Closes #30313

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-06-10 18:29:08 -03:00
parent 8d0e03a271
commit a0ad680346
36 changed files with 862 additions and 33 deletions

View file

@ -29,6 +29,7 @@ public class OrganizationRepresentation {
private String id; private String id;
private String name; private String name;
private String alias;
private boolean enabled = true; private boolean enabled = true;
private String description; private String description;
private Map<String, List<String>> attributes; private Map<String, List<String>> attributes;
@ -52,6 +53,14 @@ public class OrganizationRepresentation {
return name; return name;
} }
public String getAlias() {
return alias;
}
public void setAlias(String alias) {
this.alias = alias;
}
public boolean isEnabled() { public boolean isEnabled() {
return this.enabled; return this.enabled;
} }

View file

@ -30,6 +30,10 @@ An organization has the following settings:
Name:: 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::
An alias for this organization, used to reference the organization internally. The alias is unique within a realm.
If not set, the value is the same as the organization name. The alias cannot change afterwards.
Domains:: Domains::
A set of one or more domains that belongs to this organization. A domain cannot be shared by different organizations A set of one or more domains that belongs to this organization. A domain cannot be shared by different organizations
within a realm. within a realm.

View file

@ -3164,6 +3164,7 @@ createOrganization=Create organization
domain=Domain 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.
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

View file

@ -9,6 +9,10 @@ import { useTranslation } from "react-i18next";
import { AttributeForm } from "../components/key-value-form/AttributeForm"; import { AttributeForm } from "../components/key-value-form/AttributeForm";
import { MultiLineInput } from "../components/multi-line-input/MultiLineInput"; import { MultiLineInput } from "../components/multi-line-input/MultiLineInput";
import { keyValueToArray } from "../components/key-value-form/key-value-convert"; import { keyValueToArray } from "../components/key-value-form/key-value-convert";
import { useParams } from "react-router-dom";
import { EditOrganizationParams } from "./routes/EditOrganization";
import { useFormContext, useWatch } from "react-hook-form";
import { useEffect } from "react";
export type OrganizationFormType = AttributeForm & export type OrganizationFormType = AttributeForm &
Omit<OrganizationRepresentation, "domains" | "attributes"> & { Omit<OrganizationRepresentation, "domains" | "attributes"> & {
@ -25,6 +29,19 @@ export const convertToOrg = (
export const OrganizationForm = () => { export const OrganizationForm = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { tab } = useParams<EditOrganizationParams>();
const { setValue, getFieldState } = useFormContext();
const name = useWatch({ name: "name" });
const isEditable = tab !== "settings";
useEffect(() => {
const { isDirty } = getFieldState("alias");
if (isEditable && !isDirty) {
setValue("alias", name);
}
}, [name, isEditable]);
return ( return (
<> <>
<TextControl <TextControl
@ -32,6 +49,12 @@ export const OrganizationForm = () => {
name="name" name="name"
rules={{ required: t("required") }} rules={{ required: t("required") }}
/> />
<TextControl
label={t("alias")}
name="alias"
labelIcon={t("organizationAliasHelp")}
isDisabled={!isEditable}
/>
<FormGroup <FormGroup
label={t("domain")} label={t("domain")}
fieldId="domain" fieldId="domain"

View file

@ -34,6 +34,7 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm {
private final RealmModel realm; private final RealmModel realm;
private final String name; private final String name;
private final String alias;
private final String description; private final String description;
private final boolean enabled; private final boolean enabled;
private final LazyLoader<OrganizationModel, MultivaluedHashMap<String, String>> attributes; private final LazyLoader<OrganizationModel, MultivaluedHashMap<String, String>> attributes;
@ -44,6 +45,7 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm {
super(revision, organization.getId()); super(revision, organization.getId());
this.realm = realm; this.realm = realm;
this.name = organization.getName(); this.name = organization.getName();
this.alias = organization.getAlias();
this.description = organization.getDescription(); this.description = organization.getDescription();
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);
@ -64,6 +66,10 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm {
return name; return name;
} }
public String getAlias() {
return alias;
}
public String getDescription() { public String getDescription() {
return description; return description;
} }

View file

@ -48,9 +48,9 @@ public class InfinispanOrganizationProvider implements OrganizationProvider {
} }
@Override @Override
public OrganizationModel create(String name) { public OrganizationModel create(String name, String alias) {
registerCountInvalidation(); registerCountInvalidation();
return orgDelegate.create(name); return orgDelegate.create(name, alias);
} }
@Override @Override

View file

@ -86,6 +86,18 @@ public class OrganizationAdapter implements OrganizationModel {
updated.setName(name); updated.setName(name);
} }
@Override
public String getAlias() {
if (isUpdated()) return updated.getAlias() ;
return cached.getAlias();
}
@Override
public void setAlias(String alias) {
getDelegateForUpdate();
updated.setAlias(alias);
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
if (isUpdated()) return updated.isEnabled(); if (isUpdated()) return updated.isEnabled();
@ -145,4 +157,17 @@ public class OrganizationAdapter implements OrganizationModel {
return delegate.isManagedMember(this, user); return delegate.isManagedMember(this, user);
} }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrganizationModel)) return false;
OrganizationModel that = (OrganizationModel) o;
return that.getId().equals(getId());
}
@Override
public int hashCode() {
return getId().hashCode();
}
} }

View file

@ -55,6 +55,9 @@ public class OrganizationEntity {
@Column(name = "NAME") @Column(name = "NAME")
private String name; private String name;
@Column(name = "ALIAS")
private String alias;
@Column(name = "ENABLED") @Column(name = "ENABLED")
private boolean enabled; private boolean enabled;
@ -82,6 +85,14 @@ public class OrganizationEntity {
this.name = name; this.name = name;
} }
public String getAlias() {
return alias;
}
public void setAlias(String alias) {
this.alias = alias;
}
public boolean isEnabled() { public boolean isEnabled() {
return this.enabled; return this.enabled;
} }

View file

@ -70,15 +70,23 @@ public class JpaOrganizationProvider implements OrganizationProvider {
} }
@Override @Override
public OrganizationModel create(String name) { public OrganizationModel create(String name, String alias) {
if (StringUtil.isBlank(name)) { if (StringUtil.isBlank(name)) {
throw new ModelValidationException("Name can not be null"); throw new ModelValidationException("Name can not be null");
} }
if (StringUtil.isBlank(alias)) {
alias = name;
}
if (getByName(name) != null) { if (getByName(name) != null) {
throw new ModelDuplicateException("A organization with the same name already exists."); throw new ModelDuplicateException("A organization with the same name already exists.");
} }
if (getAllStream(Map.of(OrganizationModel.ALIAS, alias), -1, -1).findAny().isPresent()) {
throw new ModelDuplicateException("A organization with the same alias already exists");
}
RealmModel realm = getRealm(); RealmModel realm = getRealm();
OrganizationAdapter adapter = new OrganizationAdapter(realm, this); OrganizationAdapter adapter = new OrganizationAdapter(realm, this);
@ -88,6 +96,7 @@ public class JpaOrganizationProvider implements OrganizationProvider {
adapter.setGroupId(group.getId()); adapter.setGroupId(group.getId());
adapter.setName(name); adapter.setName(name);
adapter.setAlias(alias);
adapter.setEnabled(true); adapter.setEnabled(true);
em.persist(adapter.getEntity()); em.persist(adapter.getEntity());
@ -224,7 +233,13 @@ public class JpaOrganizationProvider implements OrganizationProvider {
predicates.add(builder.equal(org.get("groupId"), group.get("id"))); predicates.add(builder.equal(org.get("groupId"), group.get("id")));
for (Map.Entry<String, String> entry : attributes.entrySet()) { for (Map.Entry<String, String> entry : attributes.entrySet()) {
if (StringUtil.isNotBlank(entry.getKey())) { if (StringUtil.isBlank(entry.getKey())) {
continue;
}
if (OrganizationModel.ALIAS.equals(entry.getKey())) {
predicates.add(builder.equal(org.get("alias"), entry.getValue()));
} else {
Join<GroupEntity, GroupAttributeEntity> groupJoin = group.join("attributes"); Join<GroupEntity, GroupAttributeEntity> groupJoin = group.join("attributes");
Predicate attrNamePredicate = builder.equal(groupJoin.get("name"), entry.getKey()); Predicate attrNamePredicate = builder.equal(groupJoin.get("name"), entry.getKey());
Predicate attrValuePredicate = builder.equal(groupJoin.get("value"), entry.getValue()); Predicate attrValuePredicate = builder.equal(groupJoin.get("value"), entry.getValue());

View file

@ -93,6 +93,25 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel<Or
return entity.getName(); return entity.getName();
} }
@Override
public String getAlias() {
return entity.getAlias();
}
@Override
public void setAlias(String alias) {
if (StringUtil.isBlank(alias)) {
alias = getName();
}
if (alias.equals(entity.getAlias())) {
return;
}
if (StringUtil.isNotBlank(entity.getAlias())) {
throw new ModelValidationException("Cannot change the alias");
}
entity.setAlias(alias);
}
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return provider.isEnabled() && entity.isEnabled(); return provider.isEnabled() && entity.isEnabled();

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--
~ * 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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<changeSet author="keycloak" id="26.0.0-org-alias">
<addColumn tableName="ORG">
<column name="ALIAS" type="VARCHAR(255)"/>
</addColumn>
<update tableName="ORG">
<column name="ALIAS" valueComputed="NAME"/>
</update>
<addNotNullConstraint tableName="ORG" columnName="ALIAS" columnDataType="VARCHAR(255)"/>
<addUniqueConstraint tableName="ORG" columnNames="REALM_ID, ALIAS" constraintName="UK_ORG_ALIAS"/>
</changeSet>
</databaseChangeLog>

View file

@ -82,5 +82,6 @@
<include file="META-INF/jpa-changelog-24.0.0.xml"/> <include file="META-INF/jpa-changelog-24.0.0.xml"/>
<include file="META-INF/jpa-changelog-24.0.2.xml"/> <include file="META-INF/jpa-changelog-24.0.2.xml"/>
<include file="META-INF/jpa-changelog-25.0.0.xml"/> <include file="META-INF/jpa-changelog-25.0.0.xml"/>
<include file="META-INF/jpa-changelog-26.0.0.xml"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -265,6 +265,7 @@ public class ExportUtils {
OrganizationRepresentation org = new OrganizationRepresentation(); OrganizationRepresentation org = new OrganizationRepresentation();
org.setName(m.getName()); org.setName(m.getName());
org.setAlias(m.getAlias());
org.setEnabled(m.isEnabled()); org.setEnabled(m.isEnabled());
org.setDescription(m.getDescription()); org.setDescription(m.getDescription());
m.getDomains().map(d -> { m.getDomains().map(d -> {

View file

@ -1589,7 +1589,8 @@ 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())) {
OrganizationModel org = provider.create(orgRep.getName()); OrganizationModel org = provider.create(orgRep.getName(), orgRep.getAlias());
org.setDomains(orgRep.getDomains().stream().map(r -> new OrganizationDomainModel(r.getName(), r.isVerified())).collect(Collectors.toSet())); org.setDomains(orgRep.getDomains().stream().map(r -> new OrganizationDomainModel(r.getName(), r.isVerified())).collect(Collectors.toSet()));
for (IdentityProviderRepresentation identityProvider : Optional.ofNullable(orgRep.getIdentityProviders()).orElse(Collections.emptyList())) { for (IdentityProviderRepresentation identityProvider : Optional.ofNullable(orgRep.getIdentityProviders()).orElse(Collections.emptyList())) {

View file

@ -28,6 +28,7 @@ public interface OrganizationModel {
String ORGANIZATION_NAME_ATTRIBUTE = "kc.org.name"; String ORGANIZATION_NAME_ATTRIBUTE = "kc.org.name";
String ORGANIZATION_DOMAIN_ATTRIBUTE = "kc.org.domain"; String ORGANIZATION_DOMAIN_ATTRIBUTE = "kc.org.domain";
String BROKER_PUBLIC = "kc.org.broker.public"; String BROKER_PUBLIC = "kc.org.broker.public";
String ALIAS = "alias";
enum IdentityProviderRedirectMode { enum IdentityProviderRedirectMode {
EMAIL_MATCH("kc.org.broker.redirect.mode.email-matches"); EMAIL_MATCH("kc.org.broker.redirect.mode.email-matches");
@ -53,6 +54,10 @@ public interface OrganizationModel {
String getName(); String getName();
String getAlias();
void setAlias(String alias);
boolean isEnabled(); boolean isEnabled();
void setEnabled(boolean enabled); void setEnabled(boolean enabled);

View file

@ -34,10 +34,11 @@ public interface OrganizationProvider extends Provider {
* Creates a new organization with given {@code name} to the realm. * Creates a new organization with given {@code name} to the realm.
* The internal ID of the organization will be created automatically. * The internal ID of the organization will be created automatically.
* @param name String name of the organization. * @param name String name of the organization.
* @throws ModelDuplicateException If there is already an organization with the given name * @param alias the alias of the organization. If not set, defaults to the value set to {@code name}. Once set, the alias is immutable.
* @throws ModelDuplicateException If there is already an organization with the given name or alias
* @return Model of the created organization. * @return Model of the created organization.
*/ */
OrganizationModel create(String name); OrganizationModel create(String name, String alias);
/** /**
* Returns a {@link OrganizationModel} by its {@code id}; * Returns a {@link OrganizationModel} by its {@code id};

View file

@ -50,7 +50,7 @@ public class UpdateProfile implements RequiredActionProvider, RequiredActionFact
public InitiatedActionSupport initiatedActionSupport() { public InitiatedActionSupport initiatedActionSupport() {
return InitiatedActionSupport.SUPPORTED; return InitiatedActionSupport.SUPPORTED;
} }
@Override @Override
public void evaluateTriggers(RequiredActionContext context) { public void evaluateTriggers(RequiredActionContext context) {
} }
@ -81,22 +81,24 @@ public class UpdateProfile implements RequiredActionProvider, RequiredActionFact
context.challenge(createResponse(context, formData, errors)); context.challenge(createResponse(context, formData, errors));
} }
} }
protected UserModel.RequiredAction getResponseAction(){ protected UserModel.RequiredAction getResponseAction(){
return UserModel.RequiredAction.UPDATE_PROFILE; return UserModel.RequiredAction.UPDATE_PROFILE;
} }
protected Response createResponse(RequiredActionContext context, MultivaluedMap<String, String> formData, List<FormMessage> errors) { protected Response createResponse(RequiredActionContext context, MultivaluedMap<String, String> formData, List<FormMessage> errors) {
LoginFormsProvider form = context.form(); LoginFormsProvider form = context.form();
if (errors != null && !errors.isEmpty()) { if (errors != null && !errors.isEmpty()) {
form.setErrors(errors); form.setErrors(errors);
} }
if(formData != null) { if(formData != null) {
form = form.setFormData(formData); form = form.setFormData(formData);
} }
form.setUser(context.getUser());
return form.createResponse(getResponseAction()); return form.createResponse(getResponseAction());
} }

View file

@ -47,6 +47,7 @@ import org.keycloak.forms.login.freemarker.model.IdpReviewProfileBean;
import org.keycloak.forms.login.freemarker.model.LoginBean; import org.keycloak.forms.login.freemarker.model.LoginBean;
import org.keycloak.forms.login.freemarker.model.LogoutConfirmBean; import org.keycloak.forms.login.freemarker.model.LogoutConfirmBean;
import org.keycloak.forms.login.freemarker.model.OAuthGrantBean; import org.keycloak.forms.login.freemarker.model.OAuthGrantBean;
import org.keycloak.forms.login.freemarker.model.OrganizationBean;
import org.keycloak.forms.login.freemarker.model.ProfileBean; import org.keycloak.forms.login.freemarker.model.ProfileBean;
import org.keycloak.forms.login.freemarker.model.RealmBean; import org.keycloak.forms.login.freemarker.model.RealmBean;
import org.keycloak.forms.login.freemarker.model.RecoveryAuthnCodeInputLoginBean; import org.keycloak.forms.login.freemarker.model.RecoveryAuthnCodeInputLoginBean;
@ -63,6 +64,7 @@ import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
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.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage; import org.keycloak.models.utils.FormMessage;
@ -101,6 +103,7 @@ import java.util.Properties;
import java.util.function.Function; import java.util.function.Function;
import static org.keycloak.models.UserModel.RequiredAction.UPDATE_PASSWORD; import static org.keycloak.models.UserModel.RequiredAction.UPDATE_PASSWORD;
import static org.keycloak.organization.utils.Organizations.resolveOrganization;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -540,6 +543,14 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
attributes.put("locale", new LocaleBean(realm, locale, b, messagesBundle)); attributes.put("locale", new LocaleBean(realm, locale, b, messagesBundle));
} }
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
OrganizationModel organization = resolveOrganization(session, user);
if (organization != null) {
attributes.put("org", new OrganizationBean(session, organization, user));
}
}
} }
if (realm != null && user != null && session != null) { if (realm != null && user != null && session != null) {
attributes.put("authenticatorConfigured", new AuthenticatorConfiguredMethod(realm, user, session)); attributes.put("authenticatorConfigured", new AuthenticatorConfiguredMethod(realm, user, session));

View file

@ -0,0 +1,71 @@
/*
* 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.forms.login.freemarker.model;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationDomainModel;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
public class OrganizationBean {
private final String name;
private final String alias;
private final Set<String> domains;
private final boolean isMember;
private final Map<String, List<String>> attributes;
public OrganizationBean(KeycloakSession session, OrganizationModel organization, UserModel user) {
this.name = organization.getName();
this.alias = organization.getAlias();
this.domains = organization.getDomains().map(OrganizationDomainModel::getName).collect(Collectors.toSet());
this.isMember = user != null && organization.equals(getOrganizationProvider(session).getByMember(user));
this.attributes = Collections.unmodifiableMap(organization.getAttributes());
}
private static OrganizationProvider getOrganizationProvider(KeycloakSession session) {
return session.getProvider(OrganizationProvider.class);
}
public String getName() {
return name;
}
public String getAlias() {
return alias;
}
public Set<String> getDomains() {
return domains;
}
public Map<String, List<String>> getAttributes() {
return attributes;
}
public boolean isMember() {
return isMember;
}
}

View file

@ -31,6 +31,7 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension; import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
@ -38,6 +39,7 @@ import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.resteasy.reactive.NoCache; import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.ModelValidationException; import org.keycloak.models.ModelValidationException;
import org.keycloak.models.OrganizationModel; import org.keycloak.models.OrganizationModel;
import org.keycloak.organization.OrganizationProvider; import org.keycloak.organization.OrganizationProvider;
@ -90,13 +92,15 @@ public class OrganizationsResource {
} }
try { try {
OrganizationModel model = provider.create(organization.getName()); OrganizationModel model = provider.create(organization.getName(), organization.getAlias());
Organizations.toModel(organization, model); Organizations.toModel(organization, model);
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 mve) {
throw ErrorResponse.error(mve.getMessage(), Response.Status.BAD_REQUEST); throw ErrorResponse.error(mve.getMessage(), Response.Status.BAD_REQUEST);
} catch (ModelDuplicateException mve) {
throw ErrorResponse.error(mve.getMessage(), Status.CONFLICT);
} }
} }
@ -138,7 +142,7 @@ public class OrganizationsResource {
/** /**
* Base path for the admin REST API for one particular organization. * Base path for the admin REST API for one particular organization.
*/ */
@Path("{id}") @Path("{id}")
public OrganizationResource get(@PathParam("id") String id) { public OrganizationResource get(@PathParam("id") String id) {
auth.realm().requireManageRealm(); auth.realm().requireManageRealm();

View file

@ -17,6 +17,7 @@
package org.keycloak.organization.authentication.authenticators.browser; package org.keycloak.organization.authentication.authenticators.browser;
import static org.keycloak.organization.utils.Organizations.getEmailDomain;
import static org.keycloak.organization.utils.Organizations.isEnabledAndOrganizationsPresent; import static org.keycloak.organization.utils.Organizations.isEnabledAndOrganizationsPresent;
import static org.keycloak.organization.utils.Organizations.resolveBroker; import static org.keycloak.organization.utils.Organizations.resolveBroker;
@ -29,6 +30,7 @@ import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthen
import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean; import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean;
import org.keycloak.forms.login.freemarker.model.IdentityProviderBean; import org.keycloak.forms.login.freemarker.model.IdentityProviderBean;
import org.keycloak.forms.login.freemarker.model.OrganizationBean;
import org.keycloak.http.HttpRequest; import org.keycloak.http.HttpRequest;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
@ -77,6 +79,14 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
return; return;
} }
OrganizationProvider provider = getOrganizationProvider();
OrganizationModel organization = provider.getByDomainName(emailDomain);
if (organization != null) {
// make sure the organization is set to the session to make it available to templates
session.setAttribute(OrganizationModel.class.getName(), organization);
}
RealmModel realm = context.getRealm(); RealmModel realm = context.getRealm();
UserModel user = session.users().getUserByEmail(realm, username); UserModel user = session.users().getUserByEmail(realm, username);
@ -91,6 +101,12 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
if (broker.isEmpty()) { if (broker.isEmpty()) {
// not a managed member, continue with the regular flow // not a managed member, continue with the regular flow
if (organization != null) {
context.form().setAttributeMapper(attributes -> {
attributes.put("org", new OrganizationBean(session, organization, user));
return attributes;
});
}
context.attempted(); context.attempted();
} else if (broker.size() == 1) { } else if (broker.size() == 1) {
// user is a managed member and associated with a broker, redirect automatically // user is a managed member and associated with a broker, redirect automatically
@ -100,9 +116,6 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
return; return;
} }
OrganizationProvider provider = getOrganizationProvider();
OrganizationModel organization = provider.getByDomainName(emailDomain);
if (organization == null || !organization.isEnabled()) { if (organization == null || !organization.isEnabled()) {
// request does not map to any organization, go to the next step/sub-flow // request does not map to any organization, go to the next step/sub-flow
context.attempted(); context.attempted();
@ -173,20 +186,6 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
context.challenge(form.createLoginUsername()); context.challenge(form.createLoginUsername());
} }
private String getEmailDomain(String email) {
if (email == null) {
return null;
}
int domainSeparator = email.indexOf('@');
if (domainSeparator == -1) {
return null;
}
return email.substring(domainSeparator + 1);
}
@Override @Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return realm.isOrganizationsEnabled(); return realm.isOrganizationsEnabled();

View file

@ -91,7 +91,7 @@ public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp
} }
Map<String, Map<String, Object>> claim = new HashMap<>(); Map<String, Map<String, Object>> claim = new HashMap<>();
claim.put(organization.getName(), Map.of()); claim.put(organization.getAlias(), Map.of());
token.getOtherClaims().put(OAuth2Constants.ORGANIZATION, claim); token.getOtherClaims().put(OAuth2Constants.ORGANIZATION, claim);
} }

View file

@ -74,7 +74,7 @@ public class OrganizationMembershipMapper extends AbstractSAMLProtocolMapper imp
AttributeType attribute = new AttributeType(ORGANIZATION_ATTRIBUTE_NAME); AttributeType attribute = new AttributeType(ORGANIZATION_ATTRIBUTE_NAME);
attribute.setFriendlyName(ORGANIZATION_ATTRIBUTE_NAME); attribute.setFriendlyName(ORGANIZATION_ATTRIBUTE_NAME);
attribute.setNameFormat(JBossSAMLURIConstants.ATTRIBUTE_FORMAT_BASIC.get()); attribute.setNameFormat(JBossSAMLURIConstants.ATTRIBUTE_FORMAT_BASIC.get());
attribute.addAttributeValue(organization.getName()); attribute.addAttributeValue(organization.getAlias());
attributeStatement.addAttribute(new AttributeStatementType.ASTChoiceType(attribute)); attributeStatement.addAttribute(new AttributeStatementType.ASTChoiceType(attribute));
} }

View file

@ -146,6 +146,7 @@ public class Organizations {
rep.setId(model.getId()); rep.setId(model.getId());
rep.setName(model.getName()); rep.setName(model.getName());
rep.setAlias(model.getAlias());
rep.setEnabled(model.isEnabled()); rep.setEnabled(model.isEnabled());
rep.setDescription(model.getDescription()); rep.setDescription(model.getDescription());
rep.setAttributes(model.getAttributes()); rep.setAttributes(model.getAttributes());
@ -168,6 +169,7 @@ public class Organizations {
} }
model.setName(rep.getName()); model.setName(rep.getName());
model.setAlias(rep.getAlias());
model.setEnabled(rep.isEnabled()); model.setEnabled(rep.isEnabled());
model.setDescription(rep.getDescription()); model.setDescription(rep.getDescription());
model.setAttributes(rep.getAttributes()); model.setAttributes(rep.getAttributes());
@ -194,4 +196,41 @@ public class Organizations {
return TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class).getToken(); return TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class).getToken();
} }
public static String getEmailDomain(String email) {
if (email == null) {
return null;
}
int domainSeparator = email.indexOf('@');
if (domainSeparator == -1) {
return null;
}
return email.substring(domainSeparator + 1);
}
public static OrganizationModel resolveOrganization(KeycloakSession session, UserModel user) {
OrganizationModel organization = (OrganizationModel) session.getAttribute(OrganizationModel.class.getName());
if (organization != null) {
return organization;
}
if (user == null) {
return null;
}
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
OrganizationModel memberOrg = provider.getByMember(user);
if (memberOrg != null) {
return memberOrg;
}
String domain = Organizations.getEmailDomain(user.getEmail());
return domain == null ? null : provider.getByDomainName(domain);
}
} }

View file

@ -5,5 +5,9 @@
}, { }, {
"name" : "incorrect", "name" : "incorrect",
"types": [ "admin" ] "types": [ "admin" ]
},
{
"name" : "organization",
"types": [ "login" ]
}] }]
} }

View file

@ -0,0 +1,25 @@
<#import "template.ftl" as layout>
<#import "user-profile-commons.ftl" as userProfileCommons>
<#import "test-org-commons.ftl" as commons>
<@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section>
<#if section = "header">
${msg("loginIdpReviewProfileTitle")}
<@commons.assertions org/>
<#elseif section = "form">
<form id="kc-idp-review-profile-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<@userProfileCommons.userProfileFormFields/>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doSubmit")}" />
</div>
</div>
</form>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,30 @@
<#import "template.ftl" as layout>
<#import "user-profile-commons.ftl" as userProfileCommons>
<#import "test-org-commons.ftl" as commons>
<@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section>
<#if section = "header">
${msg("loginProfileTitle")}
<@commons.assertions org/>
<#elseif section = "form">
<form id="kc-update-profile-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<@userProfileCommons.userProfileFormFields/>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<#if isAppInitiatedAction??>
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doSubmit")}" />
<button class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" type="submit" name="cancel-aia" value="true" formnovalidate/>${msg("doCancel")}</button>
<#else>
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doSubmit")}" />
</#if>
</div>
</div>
</form>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,88 @@
<#import "template.ftl" as layout>
<#import "test-org-commons.ftl" as commons>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username') displayInfo=(realm.password && realm.registrationAllowed && !registrationDisabled??); section>
<#if section = "header">
<@commons.assertions org/>
<#elseif section = "form">
<div id="kc-form">
<div id="kc-form-wrapper">
<#if realm.password>
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}"
method="post">
<#if !usernameHidden??>
<div class="${properties.kcFormGroupClass!}">
<label for="username"
class="${properties.kcLabelClass!}"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>
<input tabindex="1" id="username"
aria-invalid="<#if messagesPerField.existsError('username')>true</#if>"
class="${properties.kcInputClass!}" name="username"
value="${(login.username!'')}"
type="text" autofocus autocomplete="off"/>
<#if messagesPerField.existsError('username')>
<span id="input-error-username" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('username'))?no_esc}
</span>
</#if>
</div>
</#if>
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
<div id="kc-form-options">
<#if realm.rememberMe && !usernameHidden??>
<div class="checkbox">
<label>
<#if login.rememberMe??>
<input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox"
checked> ${msg("rememberMe")}
<#else>
<input tabindex="3" id="rememberMe" name="rememberMe"
type="checkbox"> ${msg("rememberMe")}
</#if>
</label>
</div>
</#if>
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<input tabindex="4"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
</div>
</form>
</#if>
</div>
</div>
<#elseif section = "info" >
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="6" href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</#if>
<#elseif section = "socialProviders" >
<#if realm.password && social?? && social.providers?has_content>
<div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}">
<hr/>
<h4>${msg("identity-provider-login-label")}</h4>
<ul class="${properties.kcFormSocialAccountListClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountListGridClass!}</#if>">
<#list social.providers as p>
<a id="social-${p.alias}" class="${properties.kcFormSocialAccountListButtonClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountGridItem!}</#if>"
type="button" href="${p.loginUrl}">
<#if p.iconClasses?has_content>
<i class="${properties.kcCommonLogoIdP!} ${p.iconClasses!}" aria-hidden="true"></i>
<span class="${properties.kcFormSocialAccountNameClass!} kc-social-icon-text">${p.displayName!}</span>
<#else>
<span class="${properties.kcFormSocialAccountNameClass!}">${p.displayName!}</span>
</#if>
</a>
</#list>
</ul>
</div>
</#if>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,116 @@
<#import "template.ftl" as layout>
<#import "test-org-commons.ftl" as commons>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section>
<#if section = "header">
<@commons.assertions org/>
<#elseif section = "form">
<div id="kc-form">
<div id="kc-form-wrapper">
<#if realm.password>
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post">
<#if !usernameHidden??>
<div class="${properties.kcFormGroupClass!}">
<label for="username" class="${properties.kcLabelClass!}"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>
<input tabindex="2" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text" autofocus autocomplete="username"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
/>
<#if messagesPerField.existsError('username','password')>
<span id="input-error" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
</span>
</#if>
</div>
</#if>
<div class="${properties.kcFormGroupClass!}">
<label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>
<div class="${properties.kcInputGroup!}">
<input tabindex="3" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="current-password"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
/>
<button class="${properties.kcFormPasswordVisibilityButtonClass!}" type="button" aria-label="${msg("showPassword")}"
aria-controls="password" data-password-toggle tabindex="4"
data-icon-show="${properties.kcFormPasswordVisibilityIconShow!}" data-icon-hide="${properties.kcFormPasswordVisibilityIconHide!}"
data-label-show="${msg('showPassword')}" data-label-hide="${msg('hidePassword')}">
<i class="${properties.kcFormPasswordVisibilityIconShow!}" aria-hidden="true"></i>
</button>
</div>
<#if usernameHidden?? && messagesPerField.existsError('username','password')>
<span id="input-error" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
</span>
</#if>
</div>
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
<div id="kc-form-options">
<#if realm.rememberMe && !usernameHidden??>
<div class="checkbox">
<label>
<#if login.rememberMe??>
<input tabindex="5" id="rememberMe" name="rememberMe" type="checkbox" checked> ${msg("rememberMe")}
<#else>
<input tabindex="5" id="rememberMe" name="rememberMe" type="checkbox"> ${msg("rememberMe")}
</#if>
</label>
</div>
</#if>
</div>
<div class="${properties.kcFormOptionsWrapperClass!}">
<#if realm.resetPasswordAllowed>
<span><a tabindex="6" href="${url.loginResetCredentialsUrl}">${msg("doForgotPassword")}</a></span>
</#if>
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
<input tabindex="7" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
</div>
</form>
</#if>
</div>
</div>
<script type="module" src="${url.resourcesPath}/js/passwordVisibility.js"></script>
<#elseif section = "info" >
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration-container">
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="8"
href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</div>
</#if>
<#elseif section = "socialProviders" >
<#if realm.password && social?? && social.providers?has_content>
<div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}">
<hr/>
<h2>${msg("identity-provider-login-label")}</h2>
<ul class="${properties.kcFormSocialAccountListClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountListGridClass!}</#if>">
<#list social.providers as p>
<li>
<a id="social-${p.alias}" class="${properties.kcFormSocialAccountListButtonClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountGridItem!}</#if>"
type="button" href="${p.loginUrl}">
<#if p.iconClasses?has_content>
<i class="${properties.kcCommonLogoIdP!} ${p.iconClasses!}" aria-hidden="true"></i>
<span class="${properties.kcFormSocialAccountNameClass!} kc-social-icon-text">${p.displayName!}</span>
<#else>
<span class="${properties.kcFormSocialAccountNameClass!}">${p.displayName!}</span>
</#if>
</a>
</li>
</#list>
</ul>
</div>
</#if>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,13 @@
<#macro assertions org="">
<#if org?has_content>
Sign-in to ${org.name} organization
<#list org.attributes?keys as key>
The ${key} is ${org.attributes[key]}
</#list>
<#if org.member>
User is member of ${org.name}
</#if>
<#else>
Sign-in to the realm
</#if>
</#macro>

View file

@ -0,0 +1,18 @@
#
# 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.
#
parent=keycloak

View file

@ -59,7 +59,7 @@ public class ServerInfoTest extends AbstractKeycloakTest {
Assert.assertNames(info.getThemes().get("account"), "base", "keycloak.v3", "custom-account-provider"); Assert.assertNames(info.getThemes().get("account"), "base", "keycloak.v3", "custom-account-provider");
Assert.assertNames(info.getThemes().get("admin"), "base", "keycloak.v2"); Assert.assertNames(info.getThemes().get("admin"), "base", "keycloak.v2");
Assert.assertNames(info.getThemes().get("email"), "base", "keycloak"); Assert.assertNames(info.getThemes().get("email"), "base", "keycloak");
Assert.assertNames(info.getThemes().get("login"), "address", "base", "environment-agnostic", "keycloak"); Assert.assertNames(info.getThemes().get("login"), "address", "base", "environment-agnostic", "keycloak", "organization");
Assert.assertNames(info.getThemes().get("welcome"), "keycloak"); Assert.assertNames(info.getThemes().get("welcome"), "keycloak");
assertNotNull(info.getEnums()); assertNotNull(info.getEnums());

View file

@ -133,6 +133,7 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
protected OrganizationRepresentation createRepresentation(String name, String... orgDomains) { protected OrganizationRepresentation createRepresentation(String name, String... orgDomains) {
OrganizationRepresentation org = new OrganizationRepresentation(); OrganizationRepresentation org = new OrganizationRepresentation();
org.setName(name); org.setName(name);
org.setAlias(name);
for (String orgDomain : orgDomains) { for (String orgDomain : orgDomains) {
OrganizationDomainRepresentation domainRep = new OrganizationDomainRepresentation(); OrganizationDomainRepresentation domainRep = new OrganizationDomainRepresentation();

View file

@ -45,11 +45,13 @@ import java.io.IOException;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.admin.client.resource.OrganizationsResource;
import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.Profile.Feature; import org.keycloak.common.Profile.Feature;
import org.keycloak.models.OrganizationModel; import org.keycloak.models.OrganizationModel;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.organization.OrganizationProvider; import org.keycloak.organization.OrganizationProvider;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.OrganizationDomainRepresentation; import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation;
@ -80,6 +82,7 @@ public class OrganizationTest extends AbstractOrganizationTest {
OrganizationRepresentation existing = organization.toRepresentation(); OrganizationRepresentation existing = organization.toRepresentation();
assertEquals(expected.getId(), existing.getId()); assertEquals(expected.getId(), existing.getId());
assertEquals(expected.getName(), existing.getName()); assertEquals(expected.getName(), existing.getName());
assertEquals(expected.getAlias(), existing.getAlias());
assertEquals(1, existing.getDomains().size()); assertEquals(1, existing.getDomains().size());
assertThat(existing.isEnabled(), is(false)); assertThat(existing.isEnabled(), is(false));
assertThat(existing.getDescription(), notNullValue()); assertThat(existing.getDescription(), notNullValue());
@ -463,4 +466,43 @@ public class OrganizationTest extends AbstractOrganizationTest {
assertEquals(9, orgProvider.count()); assertEquals(9, orgProvider.count());
}); });
} }
@Test
public void testFailUpdateAlias() {
OrganizationRepresentation rep = createOrganization();
rep.setAlias("changed");
OrganizationsResource organizations = testRealm().organizations();
OrganizationResource organization = organizations.get(rep.getId());
try (Response response = organization.update(rep)) {
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
ErrorRepresentation error = response.readEntity(ErrorRepresentation.class);
assertEquals("Cannot change the alias", error.getErrorMessage());
}
rep.setAlias(rep.getName());
try (Response response = organization.update(rep)) {
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
}
}
@Test
public void testFailDuplicatedAlias() {
OrganizationRepresentation rep = createOrganization();
OrganizationsResource organizations = testRealm().organizations();
rep.setId(null);
rep.getDomains().clear();
rep.addDomain(new OrganizationDomainRepresentation("acme-2"));
rep.setName("acme-2");
try (Response response = organizations.create(rep)) {
assertEquals(Status.CONFLICT.getStatusCode(), response.getStatus());
ErrorRepresentation error = response.readEntity(ErrorRepresentation.class);
assertEquals("A organization with the same alias already exists", error.getErrorMessage());
}
}
} }

View file

@ -0,0 +1,212 @@
/*
* 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.organization.admin;
import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage;
import java.util.List;
import java.util.Map.Entry;
import jakarta.ws.rs.core.Response;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.Profile.Feature;
import org.keycloak.models.OrganizationModel.IdentityProviderRedirectMode;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginUpdateProfilePage;
import org.keycloak.testsuite.util.UserBuilder;
@EnableFeature(Feature.ORGANIZATION)
public class OrganizationThemeTest extends AbstractOrganizationTest {
@Page
protected LoginPage loginPage;
@Page
protected LoginUpdateProfilePage updateProfilePage;
@Page
protected AppPage appPage;
@Before
public void onBefore() {
RealmResource realm = realmsResouce().realm(bc.consumerRealmName());
RealmRepresentation rep = realm.toRepresentation();
rep.setLoginTheme("organization");
realm.update(rep);
}
@Test
public void testOrganizationOnRegularLogin() {
OrganizationResource organization = testRealm().organizations().get(createOrganization("myorg", "myorg.com").getId());
IdentityProviderRepresentation broker = organization.identityProviders().getIdentityProviders().get(0);
broker.getConfig().remove(IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
testRealm().identityProviders().get(broker.getAlias()).update(broker);
UserRepresentation user = UserBuilder.create().enabled(true)
.username("tom")
.email("tom@myorg.com")
.password("password")
.firstName("Tom")
.lastName("Brady")
.build();
try (Response resp = realmsResouce().realm(bc.consumerRealmName()).users().create(user)) {
String userId = ApiUtil.getCreatedId(resp);
getCleanup(bc.consumerRealmName()).addUserId(userId);
}
// organization available to regular login page
loginPage.open(bc.consumerRealmName());
Assert.assertTrue(driver.getPageSource().contains("Sign-in to the realm"));
loginPage.loginUsername("tom@myorg.com");
Assert.assertTrue(driver.getPageSource().contains("Sign-in to myorg organization"));
Assert.assertTrue(loginPage.isPasswordInputPresent());
}
@Test
public void testOrganizationOnIdentityFirstLogin() {
OrganizationResource organization = testRealm().organizations().get(createOrganization("myorg", "myorg.com").getId());
IdentityProviderRepresentation broker = organization.identityProviders().getIdentityProviders().get(0);
broker.getConfig().remove(IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
testRealm().identityProviders().get(broker.getAlias()).update(broker);
// organization available to identity-first login page
loginPage.open(bc.consumerRealmName());
Assert.assertTrue(driver.getPageSource().contains("Sign-in to the realm"));
Assert.assertFalse(loginPage.isPasswordInputPresent());
loginPage.loginUsername("non-user@myorg.com");
Assert.assertTrue(driver.getPageSource().contains("Sign-in to myorg organization"));
Assert.assertFalse(loginPage.isPasswordInputPresent());
}
@Test
public void testOrganizationOnIdPReview() {
UserRepresentation user = UserBuilder.create().enabled(true)
.username("tom")
.password("password")
.firstName("Tom")
.lastName("Brady")
.build();
try (Response resp = realmsResouce().realm(bc.providerRealmName()).users().create(user)) {
String userId = ApiUtil.getCreatedId(resp);
getCleanup(bc.providerRealmName()).addUserId(userId);
}
createOrganization("myorg", "myorg.com");
// organization available to broker review profile
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername("tom@myorg.com");
waitForPage(driver, "sign in to", true);
Assert.assertTrue("Driver should be on the provider realm page right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.providerRealmName() + "/"));
loginPage.login(user.getUsername(), "password");
waitForPage(driver, "update account information", false);
Assert.assertTrue("Driver should be on the consumer realm page right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/"));
Assert.assertTrue(driver.getPageSource().contains("Sign-in to myorg organization"));
}
@Test
public void testOrganizationOnUpdateProfile() {
UserRepresentation user = UserBuilder.create().enabled(true)
.username("tom")
.email("tom@myorg.org")
.password("password")
.firstName("Tom")
.lastName("Brady")
.requiredAction(UserModel.RequiredAction.UPDATE_PROFILE.name())
.build();
try (Response resp = testRealm().users().create(user)) {
String userId = ApiUtil.getCreatedId(resp);
getCleanup(bc.consumerRealmName()).addUserId(userId);
}
createOrganization("myorg", "myorg.com", "myorg.org");
oauth.clientId("broker-app");
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername("tom");
loginPage.login("tom", "password");
waitForPage(driver, "update account information", false);
Assert.assertTrue("Driver should be on the consumer realm page right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/"));
Assert.assertTrue(driver.getPageSource().contains("Sign-in to myorg organization"));
}
@Test
public void testOrganizationAttributes() {
OrganizationRepresentation orgRep = createOrganization("myorg", "myorg.com");
OrganizationResource organization = testRealm().organizations().get(orgRep.getId());
IdentityProviderRepresentation broker = organization.identityProviders().getIdentityProviders().get(0);
broker.getConfig().remove(IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
testRealm().identityProviders().get(broker.getAlias()).update(broker);
// organization available to identity-first login page
loginPage.open(bc.consumerRealmName());
Assert.assertTrue(driver.getPageSource().contains("Sign-in to the realm"));
Assert.assertFalse(loginPage.isPasswordInputPresent());
loginPage.loginUsername("non-user@myorg.com");
Assert.assertTrue(driver.getPageSource().contains("Sign-in to myorg organization"));
for (Entry<String, List<String>> attribute : orgRep.getAttributes().entrySet()) {
Assert.assertTrue(driver.getPageSource().contains("The " + attribute.getKey() + " is " + attribute.getValue()));
}
Assert.assertFalse(loginPage.isPasswordInputPresent());
}
@Test
public void testUserIsMember() {
UserRepresentation user = UserBuilder.create().enabled(true)
.username("tom")
.email("tom@myorg.com")
.password("password")
.firstName("Tom")
.lastName("Brady")
.requiredAction(UserModel.RequiredAction.UPDATE_PROFILE.name())
.build();
try (Response resp = testRealm().users().create(user)) {
String userId = ApiUtil.getCreatedId(resp);
user.setId(userId);
getCleanup(bc.consumerRealmName()).addUserId(userId);
}
OrganizationRepresentation orgRep = createOrganization("myorg", "myorg.com");
OrganizationResource organization = testRealm().organizations().get(orgRep.getId());
IdentityProviderRepresentation broker = organization.identityProviders().getIdentityProviders().get(0);
broker.getConfig().remove(IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
testRealm().identityProviders().get(broker.getAlias()).update(broker);
organization.members().addMember(user.getId()).close();
// organization available to identity-first login page
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(user.getEmail());
Assert.assertTrue(driver.getPageSource().contains("Sign-in to myorg organization"));
Assert.assertTrue(driver.getPageSource().contains("User is member of " + orgRep.getName()));
Assert.assertTrue(loginPage.isPasswordInputPresent());
}
}

View file

@ -109,6 +109,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());
assertThat(organizations.stream().map(OrganizationRepresentation::getName).toList(), Matchers.containsInAnyOrder(expectedOrganizations.toArray())); assertThat(organizations.stream().map(OrganizationRepresentation::getName).toList(), Matchers.containsInAnyOrder(expectedOrganizations.toArray()));
assertThat(organizations.stream().map(OrganizationRepresentation::getAlias).toList(), Matchers.containsInAnyOrder(expectedOrganizations.toArray()));
for (OrganizationRepresentation orgRep : organizations) { for (OrganizationRepresentation orgRep : organizations) {
OrganizationResource organization = testRealm().organizations().get(orgRep.getId()); OrganizationResource organization = testRealm().organizations().get(orgRep.getId());