mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-24 02:10:34 -05:00
Changes to address Org subdomain matching (#45190)
Signed-off-by: sar <sar.haidar@gmail.com>
This commit is contained in:
+6
@@ -19,6 +19,12 @@ package org.keycloak.representations.idm;
|
||||
|
||||
/**
|
||||
* Representation implementation of an organization internet domain.
|
||||
*
|
||||
* <p>Supports pattern-based domain matching:
|
||||
* <ul>
|
||||
* <li><code>example.com</code> - exact match only</li>
|
||||
* <li><code>*.example.com</code> - matches example.com and all subdomains</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
include::organizations/intro.adoc[leveloffset=+2]
|
||||
include::organizations/managing-organization.adoc[leveloffset=+2]
|
||||
include::organizations/managing-domains.adoc[leveloffset=+2]
|
||||
include::organizations/managing-attributes.adoc[leveloffset=+2]
|
||||
include::organizations/managing-members.adoc[leveloffset=+2]
|
||||
include::organizations/managing-groups.adoc[leveloffset=+2]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
[id="managing-organization-domains_{context}"]
|
||||
|
||||
= Managing organization domains
|
||||
|
||||
The domain associated with an organization plays an important role in how
|
||||
organization members authenticate to a realm and how their profiles are validated.
|
||||
|
||||
One of the key roles of a domain is to help to identify the organizations where a user is a member. By looking at their
|
||||
email address, {project_name} will match a corresponding organization using the same domain and eventually change the
|
||||
authentication flow based on the organization requirements.
|
||||
|
||||
The domain also allows organizations to enforce that users are not allowed to use a domain in their emails
|
||||
other than those associated with an organization. This restriction is especially useful when users, and their identities,
|
||||
are federated from identity providers associated with an organization and you want to force a specific email domain for their email addresses.
|
||||
|
||||
== Assigning domains
|
||||
|
||||
Domains are assigned through the *Domains* setting when creating or editing an organization. An organization can have
|
||||
zero, one, or multiple domains.
|
||||
|
||||
When no domain is assigned, members are not validated against domain restrictions and email-based redirection to
|
||||
identity providers is unavailable.
|
||||
|
||||
== Domain types
|
||||
|
||||
Exact::
|
||||
Matches only the specified value. `example.com` matches `user@example.com` but not `user@sub.example.com`.
|
||||
|
||||
Wildcard::
|
||||
Prefix the base domain with `*.` to match the base domain and all its subdomains at any depth.
|
||||
`*.example.com` matches `user@example.com`, `user@sub.example.com`, and `user@deep.sub.example.com`.
|
||||
|
||||
== Mapping email addresses to organizations
|
||||
|
||||
{project_name} extracts the domain from the email address, normalizes it to lowercase, and selects the best-matching
|
||||
organization using the following rules:
|
||||
|
||||
. Matching is case-insensitive.
|
||||
. The most specific match wins, measured by the number of domain parts. For example, when resolving `user@deep.sub.example.com`:
|
||||
* `deep.sub.example.com` (4 parts, exact) wins over `*.sub.example.com` (4 parts, wildcard) and `*.example.com` (3 parts, wildcard).
|
||||
* `*.sub.example.com` (4 parts) wins over `*.example.com` (3 parts).
|
||||
. When two domains have the same number of parts, an exact match wins over a wildcard.
|
||||
|
||||
The same rules determine which domain is reported in token claims such as the `domain` claim from the
|
||||
organization membership mapper.
|
||||
|
||||
== Validation rules
|
||||
|
||||
* A domain cannot be shared across organizations in the same realm.
|
||||
* Domains are stored and compared in lowercase.
|
||||
* A domain must have at least 2 parts (e.g. `example.com`). Single-part domains such as `com`, `org`, or `*.com` are rejected.
|
||||
* A domain cannot have more than 10 parts.
|
||||
* The `*.` wildcard prefix may appear only once and only at the start of the value (e.g. `sub.*.example.com` is rejected).
|
||||
|
||||
== Considerations
|
||||
|
||||
Prefer the most specific value::
|
||||
Use an exact domain for a single, well-known address. Use a wildcard when new subdomains may appear over time and should
|
||||
all map to the same organization.
|
||||
|
||||
Overlapping domains across organizations are allowed but resolved deterministically::
|
||||
An organization may own `*.example.com` while another owns `sub.example.com`. Only one organization is returned per lookup,
|
||||
always the most specific match, with exact winning over wildcard at equal specificity. Verify the intended mapping
|
||||
before relying on overlapping configurations.
|
||||
|
||||
Use exact domains to model exceptions::
|
||||
An organization can hold both `*.example.com` and `vip.example.com`. Addresses under `vip.example.com` resolve to the
|
||||
exact entry; all other subdomains fall back to the wildcard.
|
||||
@@ -39,6 +39,9 @@ The identity provider you want to link to the organization. An identity provider
|
||||
Domain::
|
||||
The domain from the organization that you want to link with the identity provider.
|
||||
|
||||
Excluded domains::
|
||||
A comma-separated list of domains to skip automatic redirection. You can use wildcard domains like `*.example.com` to exclude all subdomains of `example.com`.
|
||||
|
||||
Hide on login page::
|
||||
If this identity provider should be hidden in login pages when the user is authenticating in the scope of the organization.
|
||||
|
||||
|
||||
@@ -59,16 +59,6 @@ Once you create an organization, you can manage the additional settings that are
|
||||
* <<_managing_groups_,Manage groups>>
|
||||
* <<_managing_identity_provider_,Manage identity providers>>
|
||||
|
||||
== Understanding organization domains
|
||||
|
||||
When managing an organization, the domain associated with an organization plays an important role in how
|
||||
organization members authenticate to a realm and how their profiles are validated.
|
||||
|
||||
One of the key roles of a domain is to help to identify the organizations where a user is a member. By looking at their email address, {project_name} will match a corresponding organization using the same domain and eventually change the authentication flow based on the organization requirements.
|
||||
|
||||
The domain also allows organizations to enforce that users are not allowed to use a domain in their emails
|
||||
other than those associated with an organization. This restriction is especially useful when users, and their identities, are federated from identity providers associated with an organization and you want to force a specific email domain for their email addresses.
|
||||
|
||||
== Disabling an organization
|
||||
|
||||
To disable an organization, toggle *Enabled* to *Off*.
|
||||
|
||||
+2
@@ -3829,3 +3829,5 @@ role_offline-access=Offline access
|
||||
role_uma_authorization=Obtain permissions
|
||||
scimApiEnabled=SCIM API
|
||||
scimApiEnabledHelp=If enabled, exposes realm resources through an API based on the System for Cross-domain Identity Management (SCIM) specification, namely RFC7643 and RFC7644.
|
||||
excludedDomains=Excluded domains
|
||||
excludedDomainsHelp=A comma-separated list of domains to skip automatic redirection. You can use wildcard domains like `*.example.com` to exclude all subdomains of `example.com`. For instance, `*.example.com, example.com`.
|
||||
@@ -1,5 +1,9 @@
|
||||
import IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
|
||||
import { FormSubmitButton, SelectControl } from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
FormSubmitButton,
|
||||
SelectControl,
|
||||
TextControl,
|
||||
} from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
Button,
|
||||
ButtonVariant,
|
||||
@@ -32,6 +36,7 @@ type LinkRepresentation = {
|
||||
hideOnLogin: boolean;
|
||||
config: {
|
||||
"kc.org.domain": string;
|
||||
"kc.org.excluded.domains": string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -147,6 +152,11 @@ export const LinkIdentityProviderModal = ({
|
||||
]}
|
||||
menuAppendTo="parent"
|
||||
/>
|
||||
<TextControl
|
||||
label={t("excludedDomains")}
|
||||
name={convertAttributeNameToForm("config.kc.org.excluded.domains")}
|
||||
labelIcon={t("excludedDomainsHelp")}
|
||||
/>
|
||||
<DefaultSwitchControl
|
||||
name="hideOnLogin"
|
||||
label={t("hideOnLoginPage")}
|
||||
|
||||
+30
-1
@@ -16,7 +16,11 @@
|
||||
*/
|
||||
package org.keycloak.models.cache.infinispan.organization;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
@@ -34,6 +38,8 @@ import org.keycloak.models.cache.infinispan.entities.InRealm;
|
||||
|
||||
public class CachedOrganization extends AbstractRevisioned implements InRealm {
|
||||
|
||||
public static final int DOMAIN_NAMES_CACHE_MAX_SIZE = 100;
|
||||
|
||||
private final String realm;
|
||||
private final String name;
|
||||
private final String alias;
|
||||
@@ -42,9 +48,10 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm {
|
||||
private final boolean enabled;
|
||||
private final LazyLoader<OrganizationModel, MultivaluedHashMap<String, String>> attributes;
|
||||
private final Set<OrganizationDomainModel> domains;
|
||||
private final Map<String, String> domainNames;
|
||||
private final Set<IdentityProviderModel> idps;
|
||||
|
||||
public CachedOrganization(long revision, RealmModel realm, OrganizationModel organization) {
|
||||
public CachedOrganization(long revision, RealmModel realm, OrganizationModel organization, Consumer<String> invalidateDomain) {
|
||||
super(revision, organization.getId());
|
||||
this.realm = realm.getId();
|
||||
this.name = organization.getName();
|
||||
@@ -54,6 +61,20 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm {
|
||||
this.enabled = organization.isEnabled();
|
||||
this.attributes = new DefaultLazyLoader<>(orgModel -> new MultivaluedHashMap<>(orgModel.getAttributes()), MultivaluedHashMap::new);
|
||||
this.domains = organization.getDomains().collect(Collectors.toSet());
|
||||
this.domainNames = Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry eldest) {
|
||||
boolean remove = domainNames.size() > DOMAIN_NAMES_CACHE_MAX_SIZE;
|
||||
|
||||
if (remove) {
|
||||
String domain = eldest.getKey().toString();
|
||||
invalidateDomain.accept(domain);
|
||||
}
|
||||
|
||||
return remove;
|
||||
}
|
||||
});
|
||||
organization.getDomains().forEach(domain -> domainNames.put(domain.getName(), domain.getName()));
|
||||
this.idps = organization.getIdentityProviders().collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@@ -90,6 +111,14 @@ public class CachedOrganization extends AbstractRevisioned implements InRealm {
|
||||
return domains.stream();
|
||||
}
|
||||
|
||||
public Set<String> getDomainNames() {
|
||||
return domainNames.keySet();
|
||||
}
|
||||
|
||||
public void addDomainName(String domainName) {
|
||||
domainNames.put(domainName, domainName);
|
||||
}
|
||||
|
||||
public Stream<IdentityProviderModel> getIdentityProviders() {
|
||||
return idps.stream();
|
||||
}
|
||||
|
||||
+20
-11
@@ -26,7 +26,6 @@ import org.keycloak.authorization.fgap.AdminPermissionsSchema;
|
||||
import org.keycloak.models.GroupModel;
|
||||
import org.keycloak.models.IdentityProviderModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.OrganizationDomainModel;
|
||||
import org.keycloak.models.OrganizationModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
@@ -70,7 +69,7 @@ public class InfinispanOrganizationProvider implements OrganizationProvider {
|
||||
@Override
|
||||
public OrganizationModel create(String id, String name, String alias) {
|
||||
registerCountInvalidation();
|
||||
return getDelegate().create(id, name, alias);
|
||||
return getById(getDelegate().create(id, name, alias).getId());
|
||||
}
|
||||
|
||||
OrganizationProvider getDelegate() {
|
||||
@@ -105,10 +104,8 @@ public class InfinispanOrganizationProvider implements OrganizationProvider {
|
||||
OrganizationModel model = getDelegate().getById(id);
|
||||
if (model == null) return null;
|
||||
if (isRealmCacheKeyInvalid(id)) return model;
|
||||
cached = new CachedOrganization(loaded, getRealm(), model);
|
||||
cached = new CachedOrganization(loaded, getRealm(), model, d -> realmCache.registerInvalidation(cacheKeyByDomain(d)));
|
||||
realmCache.getCache().addRevisioned(cached, realmCache.getStartupRevision());
|
||||
|
||||
// no need to check for realm invalidation as IdP changes are handled by events within InfinispanOrganizationProviderFactory
|
||||
} else if (isRealmCacheKeyInvalid(id)) {
|
||||
return getDelegate().getById(id);
|
||||
} else if (managedOrganizations.containsKey(id)) {
|
||||
@@ -140,6 +137,10 @@ public class InfinispanOrganizationProvider implements OrganizationProvider {
|
||||
}
|
||||
cached = new CachedOrganizationIds(loaded, cacheKey, getRealm(), Stream.ofNullable(model));
|
||||
realmCache.getCache().addRevisioned(cached, realmCache.getStartupRevision());
|
||||
model = getById(model.getId());
|
||||
if (model instanceof OrganizationAdapter ma) {
|
||||
ma.getCached().addDomainName(domainName);
|
||||
}
|
||||
}
|
||||
|
||||
return cached.getOrgIds().stream().map(this::getById).filter(Objects::nonNull).findAny().orElse(null);
|
||||
@@ -442,10 +443,13 @@ public class InfinispanOrganizationProvider implements OrganizationProvider {
|
||||
if (realmCache != null) {
|
||||
realmCache.registerInvalidation(cacheKeyOrgId(getRealm(), id));
|
||||
realmCache.registerInvalidation(id);
|
||||
organization.getDomains()
|
||||
.map(OrganizationDomainModel::getName)
|
||||
.map(this::cacheKeyByDomain)
|
||||
.forEach(realmCache::registerInvalidation);
|
||||
CachedOrganization cachedOrg = realmCache.getCache().get(id, CachedOrganization.class);
|
||||
|
||||
if (cachedOrg != null) {
|
||||
cachedOrg.getDomainNames().stream()
|
||||
.map(this::cacheKeyByDomain)
|
||||
.forEach(realmCache::registerInvalidation);
|
||||
}
|
||||
}
|
||||
|
||||
OrganizationAdapter adapter = managedOrganizations.get(id);
|
||||
@@ -473,11 +477,16 @@ public class InfinispanOrganizationProvider implements OrganizationProvider {
|
||||
return backendOrganizations.map(OrganizationModel::getId).map(this::getById);
|
||||
}
|
||||
|
||||
private String cacheKeyByDomain(String domainName) {
|
||||
public static String cacheKeyByDomain(RealmModel realm, String domainName) {
|
||||
if (domainName == null) {
|
||||
throw new IllegalArgumentException("domainName must not be null");
|
||||
}
|
||||
return getRealm().getId() + ".org.domain.name." + domainName.toLowerCase();
|
||||
return realm.getId() + ".org.domain.name." + domainName.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
private String cacheKeyByDomain(String domainName) {
|
||||
return cacheKeyByDomain(getRealm(), domainName);
|
||||
}
|
||||
|
||||
private String cacheKeyByMember(UserModel user) {
|
||||
|
||||
+21
@@ -29,6 +29,7 @@ import org.keycloak.models.OrganizationModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.cache.infinispan.LazyModel;
|
||||
import org.keycloak.organization.OrganizationProvider;
|
||||
import org.keycloak.organization.utils.Organizations;
|
||||
|
||||
public class OrganizationAdapter implements OrganizationModel {
|
||||
|
||||
@@ -159,6 +160,7 @@ public class OrganizationAdapter implements OrganizationModel {
|
||||
@Override
|
||||
public void setDomains(Set<OrganizationDomainModel> domains) {
|
||||
getDelegateForUpdate();
|
||||
invalidateDomains(domains);
|
||||
updated.setDomains(domains);
|
||||
}
|
||||
|
||||
@@ -193,4 +195,23 @@ public class OrganizationAdapter implements OrganizationModel {
|
||||
public int hashCode() {
|
||||
return getId().hashCode();
|
||||
}
|
||||
|
||||
CachedOrganization getCached() {
|
||||
return cached;
|
||||
}
|
||||
|
||||
private void invalidateDomains(Set<OrganizationDomainModel> domains) {
|
||||
for (OrganizationDomainModel domain : domains) {
|
||||
String name = domain.getName();
|
||||
OrganizationModel org = organizationCache.getByDomainName(name);
|
||||
|
||||
if (org == null && name.startsWith("*.")) {
|
||||
org = Organizations.resolveOrganization(session, null, name);
|
||||
}
|
||||
|
||||
if (org != null && !this.equals(org)) {
|
||||
organizationCache.registerOrganizationInvalidation(org);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ import org.keycloak.utils.StringUtil;
|
||||
@NamedQueries({
|
||||
@NamedQuery(name="getByOrgName", query="select distinct o from OrganizationEntity o where o.realmId = :realmId AND o.name = :name"),
|
||||
@NamedQuery(name="getByDomainName", query="select distinct o from OrganizationEntity o inner join OrganizationDomainEntity d ON o.id = d.organization.id" +
|
||||
" where o.realmId = :realmId AND d.name = :name"),
|
||||
" where o.realmId = :realmId and d.name in (:names)"),
|
||||
@NamedQuery(name="getCount", query="select count(o) from OrganizationEntity o where o.realmId = :realmId"),
|
||||
@NamedQuery(name="deleteOrganizationsByRealm", query="delete from OrganizationEntity o where o.realmId = :realmId")
|
||||
})
|
||||
|
||||
@@ -77,6 +77,8 @@ import static org.keycloak.models.UserModel.LAST_NAME;
|
||||
import static org.keycloak.models.UserModel.USERNAME;
|
||||
import static org.keycloak.models.jpa.PaginationUtils.paginateQuery;
|
||||
import static org.keycloak.organization.utils.Organizations.isReadOnlyOrganizationMember;
|
||||
import static org.keycloak.organization.utils.Organizations.resolveByDomain;
|
||||
import static org.keycloak.organization.utils.Organizations.validateDomain;
|
||||
import static org.keycloak.utils.StreamsUtil.closing;
|
||||
|
||||
public class JpaOrganizationProvider implements OrganizationProvider {
|
||||
@@ -235,18 +237,51 @@ public class JpaOrganizationProvider implements OrganizationProvider {
|
||||
|
||||
@Override
|
||||
public OrganizationModel getByDomainName(String domain) {
|
||||
TypedQuery<OrganizationEntity> query = em.createNamedQuery("getByDomainName", OrganizationEntity.class);
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String emailDomain = domain.toLowerCase();
|
||||
|
||||
validateDomain(emailDomain);
|
||||
|
||||
RealmModel realm = getRealm();
|
||||
TypedQuery<OrganizationEntity> query = em.createNamedQuery("getByDomainName", OrganizationEntity.class);
|
||||
|
||||
query.setParameter("realmId", realm.getId());
|
||||
query.setParameter("name", domain.toLowerCase());
|
||||
|
||||
List<String> domainPatterns = new ArrayList<>();
|
||||
|
||||
// Add exact match
|
||||
domainPatterns.add(emailDomain);
|
||||
|
||||
query.setParameter("names", domainPatterns);
|
||||
|
||||
try {
|
||||
OrganizationEntity entity = query.getSingleResult();
|
||||
return new OrganizationAdapter(session, realm, entity, this);
|
||||
} catch (NoResultException nre) {
|
||||
return null;
|
||||
} catch (NoResultException ignore) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strip subdomains to check for parent wildcard domains
|
||||
String[] parts = emailDomain.split("\\.");
|
||||
|
||||
// Also check for wildcard at the current level
|
||||
domainPatterns.add("*." + emailDomain);
|
||||
|
||||
for (int i = 1; i < parts.length - 1; i++) {
|
||||
String parentDomain = String.join(".", java.util.Arrays.copyOfRange(parts, i, parts.length));
|
||||
// Check for both exact parent and wildcard parent
|
||||
domainPatterns.add(parentDomain);
|
||||
domainPatterns.add("*." + parentDomain);
|
||||
}
|
||||
|
||||
query.setParameter("names", domainPatterns);
|
||||
|
||||
return resolveByDomain(query.getResultList().stream()
|
||||
.map(entity -> getById(entity.getId())).filter(Objects::nonNull).toList(), emailDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<OrganizationModel> getAllStream(String search, Boolean exact, Integer first, Integer max) {
|
||||
CriteriaBuilder builder = em.getCriteriaBuilder();
|
||||
|
||||
@@ -39,7 +39,7 @@ import org.keycloak.models.jpa.entities.OrganizationDomainEntity;
|
||||
import org.keycloak.models.jpa.entities.OrganizationEntity;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.organization.OrganizationProvider;
|
||||
import org.keycloak.utils.EmailValidationUtil;
|
||||
import org.keycloak.organization.utils.Organizations;
|
||||
import org.keycloak.utils.StringUtil;
|
||||
|
||||
import static java.util.Optional.ofNullable;
|
||||
@@ -184,9 +184,10 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel<Or
|
||||
.collect(Collectors.toMap(OrganizationDomainModel::getName, Function.identity()));
|
||||
|
||||
for (OrganizationDomainEntity domainEntity : new HashSet<>(this.entity.getDomains())) {
|
||||
// update the existing domain (for now, only the verified flag can be changed).
|
||||
// update the existing domain (verified flag can be changed).
|
||||
if (modelMap.containsKey(domainEntity.getName())) {
|
||||
domainEntity.setVerified(modelMap.get(domainEntity.getName()).isVerified());
|
||||
OrganizationDomainModel model = modelMap.get(domainEntity.getName());
|
||||
domainEntity.setVerified(model.isVerified());
|
||||
modelMap.remove(domainEntity.getName());
|
||||
} else {
|
||||
// remove domain that is not found in the new set.
|
||||
@@ -276,14 +277,20 @@ public final class OrganizationAdapter implements OrganizationModel, JpaModel<Or
|
||||
private OrganizationDomainModel validateDomain(OrganizationDomainModel domainModel) {
|
||||
String domainName = domainModel.getName();
|
||||
|
||||
// we rely on the same validation util used by the EmailValidator to ensure the domain part is consistently validated.
|
||||
if (StringUtil.isBlank(domainName) || !EmailValidationUtil.isValidEmail("nouser@" + domainName)) {
|
||||
throw new ModelValidationException("The specified domain is invalid: " + domainName);
|
||||
if (StringUtil.isBlank(domainName)) {
|
||||
throw new ModelValidationException("Domain name cannot be empty");
|
||||
}
|
||||
|
||||
Organizations.validateDomain(domainName);
|
||||
|
||||
// Check for conflicts with other organizations
|
||||
OrganizationModel orgModel = provider.getByDomainName(domainName);
|
||||
if (orgModel != null && !Objects.equals(getId(), orgModel.getId())) {
|
||||
throw new ModelValidationException("Domain " + domainName + " is already linked to another organization in realm " + realm.getName());
|
||||
|
||||
if (orgModel != null && !Objects.equals(getId(), orgModel.getId())
|
||||
&& orgModel.getDomains().anyMatch(d -> d.getName().equalsIgnoreCase(domainName))) {
|
||||
throw new ModelValidationException("Domain " + domainName + " is already linked to organization " + orgModel.getName() + " in realm " + realm.getName());
|
||||
}
|
||||
|
||||
return domainModel;
|
||||
}
|
||||
|
||||
|
||||
@@ -1745,7 +1745,6 @@ public class RepresentationToModel {
|
||||
model.setAttributes(rep.getAttributes());
|
||||
model.setDomains(ofNullable(rep.getDomains()).orElse(Set.of()).stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(domain -> StringUtil.isNotBlank(domain.getName()))
|
||||
.map(RepresentationToModel::toModel)
|
||||
.collect(Collectors.toSet()));
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Model implementation of an organization internet domain.
|
||||
*
|
||||
* <p>Supports pattern-based domain matching:
|
||||
* <ul>
|
||||
* <li><code>example.com</code> - exact match only</li>
|
||||
* <li><code>*.example.com</code> - matches example.com and all subdomains</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,7 @@ public interface OrganizationModel {
|
||||
String ORGANIZATION_SWITCHABLE_ATTRIBUTE = "kc.org.switchable";
|
||||
String ORGANIZATION_NAME_ATTRIBUTE = "kc.org.name";
|
||||
String ORGANIZATION_DOMAIN_ATTRIBUTE = "kc.org.domain";
|
||||
String ORGANIZATION_EXCLUDED_DOMAIN_ATTRIBUTE = "kc.org.excluded.domains";
|
||||
String ALIAS = "alias";
|
||||
String HIDE_IDP_ON_LOGIN_WHEN_ORGANIZATION_UNKNOWN = "kc.org.broker.login.hide-when-org-unknown";
|
||||
String SHOW_IDP_ON_LOGIN_WHEN_LINKED_ELSEWHERE = "kc.org.broker.login.show-when-linked-elsewhere";
|
||||
|
||||
+30
-13
@@ -64,6 +64,7 @@ import static org.keycloak.authentication.AuthenticatorUtil.isSSOAuthentication;
|
||||
import static org.keycloak.models.OrganizationDomainModel.ANY_DOMAIN;
|
||||
import static org.keycloak.models.utils.KeycloakModelUtils.findUserByNameOrEmail;
|
||||
import static org.keycloak.organization.utils.Organizations.getEmailDomain;
|
||||
import static org.keycloak.organization.utils.Organizations.getMatchingDomain;
|
||||
import static org.keycloak.organization.utils.Organizations.isEnabledAndOrganizationsPresent;
|
||||
import static org.keycloak.organization.utils.Organizations.resolveHomeBroker;
|
||||
import static org.keycloak.utils.StringUtil.isBlank;
|
||||
@@ -289,22 +290,38 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||
return false;
|
||||
}
|
||||
|
||||
// first look for an IDP that matches exactly the specified domain (case-insensitive)
|
||||
IdentityProviderModel idp = organization.getIdentityProviders()
|
||||
.filter(broker -> IdentityProviderRedirectMode.EMAIL_MATCH.isSet(broker) &&
|
||||
domain.equalsIgnoreCase(broker.getConfig().get(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE))).findFirst().orElse(null);
|
||||
OrganizationDomainModel matching = getMatchingDomain(domain, organization);
|
||||
|
||||
if (idp != null) {
|
||||
// redirect the user using the broker that matches the specified domain
|
||||
redirect(context, idp.getAlias(), username);
|
||||
return true;
|
||||
if (matching == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// look for an idp that can match any of the org domains (case-insensitive)
|
||||
idp = organization.getIdentityProviders().filter(IdentityProviderRedirectMode.EMAIL_MATCH::isSet)
|
||||
.filter(broker -> ANY_DOMAIN.equals(broker.getConfig().get(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE)))
|
||||
.filter(broker -> organization.getDomains().map(OrganizationDomainModel::getName).anyMatch(domain::equalsIgnoreCase))
|
||||
.findFirst().orElse(null);
|
||||
// first look for an IDP that matches exactly the specified domain (case-insensitive)
|
||||
IdentityProviderModel idp = organization.getIdentityProviders()
|
||||
.filter(IdentityProviderRedirectMode.EMAIL_MATCH::isSet)
|
||||
.filter(broker -> {
|
||||
String brokerDomain = broker.getConfig().get(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE);
|
||||
|
||||
if (brokerDomain == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String excludedDomains = broker.getConfig().get(OrganizationModel.ORGANIZATION_EXCLUDED_DOMAIN_ATTRIBUTE);
|
||||
|
||||
if (excludedDomains != null) {
|
||||
for (String excludedDomain : excludedDomains.split(",")) {
|
||||
if (Organizations.isSameDomain(domain, excludedDomain.trim())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ANY_DOMAIN.equals(brokerDomain)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return brokerDomain.equals(matching.getName());
|
||||
}).findFirst().orElse(null);
|
||||
|
||||
if (idp != null) {
|
||||
redirect(context, idp.getAlias(), username);
|
||||
|
||||
+20
@@ -30,6 +30,7 @@ import org.keycloak.common.Profile;
|
||||
import org.keycloak.models.ClientSessionContext;
|
||||
import org.keycloak.models.KeycloakContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.OrganizationDomainModel;
|
||||
import org.keycloak.models.OrganizationModel;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
@@ -37,6 +38,7 @@ import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.models.utils.ModelToRepresentation;
|
||||
import org.keycloak.models.utils.RepresentationToModel;
|
||||
import org.keycloak.organization.utils.Organizations;
|
||||
import org.keycloak.protocol.ProtocolMapperUtils;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper;
|
||||
@@ -57,6 +59,7 @@ public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp
|
||||
public static final String PROVIDER_ID = "oidc-organization-membership-mapper";
|
||||
public static final String ADD_ORGANIZATION_ATTRIBUTES = "addOrganizationAttributes";
|
||||
public static final String ADD_ORGANIZATION_ID = "addOrganizationId";
|
||||
public static final String ADD_ORGANIZATION_DOMAIN = "addOrganizationDomain";
|
||||
|
||||
@Override
|
||||
public List<ProviderConfigProperty> getConfigProperties() {
|
||||
@@ -85,6 +88,13 @@ public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp
|
||||
property.setDefaultValue(Boolean.FALSE.toString());
|
||||
property.setHelpText(ADD_ORGANIZATION_ID + ".help");
|
||||
properties.add(property);
|
||||
property = new ProviderConfigProperty();
|
||||
property.setName(ADD_ORGANIZATION_DOMAIN);
|
||||
property.setLabel(ADD_ORGANIZATION_DOMAIN + ".label");
|
||||
property.setType(ProviderConfigProperty.BOOLEAN_TYPE);
|
||||
property.setDefaultValue(Boolean.FALSE.toString());
|
||||
property.setHelpText(ADD_ORGANIZATION_DOMAIN + ".help");
|
||||
properties.add(property);
|
||||
return properties;
|
||||
}
|
||||
|
||||
@@ -175,6 +185,12 @@ public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp
|
||||
claims.put(OAuth2Constants.ORGANIZATION_ID, o.getId());
|
||||
}
|
||||
|
||||
if (isAddOrganizationDomain(model)) {
|
||||
OrganizationDomainModel domain = Organizations.getMatchingDomain(Organizations.getEmailDomain(user.getEmail()), o);
|
||||
|
||||
claims.put("domain", domain == null ? null : domain.getName());
|
||||
}
|
||||
|
||||
value.put(o.getAlias(), claims);
|
||||
}
|
||||
|
||||
@@ -235,6 +251,10 @@ public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp
|
||||
return Boolean.parseBoolean(model.getConfig().getOrDefault(ADD_ORGANIZATION_ID, Boolean.FALSE.toString()));
|
||||
}
|
||||
|
||||
private boolean isAddOrganizationDomain(ProtocolMapperModel model) {
|
||||
return Boolean.parseBoolean(model.getConfig().getOrDefault(ADD_ORGANIZATION_DOMAIN, Boolean.FALSE.toString()));
|
||||
}
|
||||
|
||||
public static ProtocolMapperModel create(String name, boolean accessToken, boolean idToken, boolean introspectionEndpoint) {
|
||||
ProtocolMapperModel mapper = new ProtocolMapperModel();
|
||||
mapper.setName(name);
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
package org.keycloak.organization.utils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import jakarta.ws.rs.ForbiddenException;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
@@ -43,6 +43,7 @@ import org.keycloak.models.GroupModel.Type;
|
||||
import org.keycloak.models.IdentityProviderModel;
|
||||
import org.keycloak.models.KeycloakContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ModelValidationException;
|
||||
import org.keycloak.models.OrganizationDomainModel;
|
||||
import org.keycloak.models.OrganizationModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
@@ -53,12 +54,18 @@ import org.keycloak.services.ErrorResponse;
|
||||
import org.keycloak.services.Urls;
|
||||
import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
import org.keycloak.utils.EmailValidationUtil;
|
||||
|
||||
import static java.util.Optional.of;
|
||||
import static java.util.Optional.ofNullable;
|
||||
|
||||
import static org.keycloak.utils.StringUtil.isBlank;
|
||||
|
||||
public class Organizations {
|
||||
|
||||
private static final String WILDCARD_PREFIX = "*.";
|
||||
private static final int MIN_DOMAIN_PARTS = 2;
|
||||
private static final int MAX_DOMAIN_PARTS = 10;
|
||||
|
||||
public static boolean isOrganizationGroup(GroupModel group) {
|
||||
return Type.ORGANIZATION.equals(group.getType()) && group.getOrganization() != null;
|
||||
}
|
||||
@@ -180,6 +187,102 @@ public class Organizations {
|
||||
return verifier.verify().getToken();
|
||||
}
|
||||
|
||||
public static int getDomainPartsSize(String domain) {
|
||||
if (isBlank(domain)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.toIntExact(domain.chars().filter(c -> c == '.').count()) + 1;
|
||||
}
|
||||
|
||||
public static void validateDomain(String rawDomain) {
|
||||
if (rawDomain == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String domain = rawDomain;
|
||||
|
||||
if (rawDomain.contains(WILDCARD_PREFIX)) {
|
||||
if (rawDomain.length() == WILDCARD_PREFIX.length()) {
|
||||
throw new ModelValidationException("Wildcard domain must specify a base domain: " + rawDomain);
|
||||
}
|
||||
|
||||
if (!rawDomain.startsWith(WILDCARD_PREFIX)) {
|
||||
throw new ModelValidationException("Wildcard domain must start with the wildcard");
|
||||
}
|
||||
|
||||
domain = rawDomain.substring(2);
|
||||
|
||||
if (domain.contains("*")) {
|
||||
throw new ModelValidationException("Multiple wildcards are not allowed: " + rawDomain);
|
||||
}
|
||||
|
||||
int parts = getDomainPartsSize(domain);
|
||||
|
||||
if (parts < MIN_DOMAIN_PARTS) {
|
||||
throw new ModelValidationException("Domain must have at least " + MIN_DOMAIN_PARTS + " parts (e.g. 'example.com'): " + domain);
|
||||
}
|
||||
|
||||
if (parts > MAX_DOMAIN_PARTS) {
|
||||
throw new ModelValidationException("Domain has too many parts (max " + MAX_DOMAIN_PARTS + " allowed): " + domain);
|
||||
}
|
||||
}
|
||||
|
||||
if (isBlank(domain) || !EmailValidationUtil.isValidEmail("user@" + domain)) {
|
||||
throw new ModelValidationException("Invalid domain format: " + rawDomain);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the most specific matching organization domain for the given {@code domain} and
|
||||
* {@code organization}. When several domains of the organization match (e.g. an exact domain
|
||||
* and a parent wildcard, or nested wildcards), the one with the largest number of parts wins.
|
||||
*
|
||||
* @param domain the domain
|
||||
* @param organization the organization
|
||||
* @return the most specific matching organization domain, or {@code null} if no match is found
|
||||
*/
|
||||
public static OrganizationDomainModel getMatchingDomain(String domain, OrganizationModel organization) {
|
||||
if (domain == null || organization == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<OrganizationDomainModel> domains = organization.getDomains().filter(model -> isSameDomain(domain, model))
|
||||
// sorted ascending by number of domain parts so the most specific match is the last element
|
||||
.sorted(Comparator.comparingInt(o -> getDomainPartsSize(o.getName())))
|
||||
.toList();
|
||||
|
||||
if (domains.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return domains.get(domains.size() - 1);
|
||||
}
|
||||
|
||||
public static boolean isSameDomain(String domain, OrganizationDomainModel model) {
|
||||
return isSameDomain(domain, ofNullable(model).map(OrganizationDomainModel::getName).orElse(null));
|
||||
}
|
||||
|
||||
public static boolean isSameDomain(String domain, String expectedDomain) {
|
||||
if (domain == null || expectedDomain == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String canonicalDomain = domain.toLowerCase();
|
||||
String pattern = expectedDomain.toLowerCase();
|
||||
|
||||
if (canonicalDomain.equals(pattern)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.startsWith(WILDCARD_PREFIX)) {
|
||||
String baseDomain = pattern.substring(2);
|
||||
return canonicalDomain.equals(baseDomain) || canonicalDomain.endsWith("." + baseDomain);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static String getEmailDomain(String email) {
|
||||
if (email == null) {
|
||||
return null;
|
||||
@@ -231,6 +334,7 @@ public class Organizations {
|
||||
}
|
||||
|
||||
AuthenticationSessionModel authSession = context.getAuthenticationSession();
|
||||
String emailDomain = ofNullable(domain).orElseGet(() -> getEmailDomain(user));
|
||||
|
||||
if (authSession != null) {
|
||||
OrganizationScope scope = OrganizationScope.valueOfScope(session);
|
||||
@@ -247,13 +351,13 @@ public class Organizations {
|
||||
}
|
||||
|
||||
// make sure the user still maps to the organization from the authentication session
|
||||
if (organization.isMember(user) || matchesOrganizationDomain(organization, user, domain)) {
|
||||
if (organization.isMember(user)) {
|
||||
return organization;
|
||||
}
|
||||
|
||||
return null;
|
||||
return resolveByDomain(organizations, emailDomain);
|
||||
} else if (scope != null && user != null) {
|
||||
return resolveUserOrganization(organizations, user, domain).orElse(null);
|
||||
return resolveByDomain(organizations, emailDomain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,11 +367,25 @@ public class Organizations {
|
||||
.toList();
|
||||
|
||||
if (organizations.size() == 1) {
|
||||
// single membership found, return the org
|
||||
return organizations.get(0);
|
||||
}
|
||||
|
||||
return resolveUserOrganization(organizations, user, domain)
|
||||
.orElseGet(() -> resolveOrganizationByDomain(user, domain, provider));
|
||||
if (organizations.isEmpty()) {
|
||||
// no membership, any org that matches the domain
|
||||
return resolveByDomain(ofNullable(emailDomain)
|
||||
.map(provider::getByDomainName)
|
||||
.map(List::of)
|
||||
.orElse(List.of()), emailDomain);
|
||||
}
|
||||
|
||||
for (OrganizationModel organization : organizations) {
|
||||
if (organization.isManaged(user)) {
|
||||
return organization;
|
||||
}
|
||||
}
|
||||
|
||||
return resolveByDomain(organizations, emailDomain);
|
||||
}
|
||||
|
||||
public static OrganizationProvider getProvider(KeycloakSession session) {
|
||||
@@ -300,45 +418,30 @@ public class Organizations {
|
||||
(!organizationProvider.isEnabled() && org.isManaged(delegate)));
|
||||
}
|
||||
|
||||
private static boolean matchesOrganizationDomain(OrganizationModel organization, UserModel user, String domain) {
|
||||
if (organization == null) {
|
||||
return false;
|
||||
}
|
||||
public static OrganizationModel resolveByDomain(List<OrganizationModel> organizations, String domain) {
|
||||
int bestParts = -1;
|
||||
OrganizationModel organization = null;
|
||||
|
||||
String emailDomain = Optional.ofNullable(domain).orElseGet(() -> getEmailDomain(user));
|
||||
for (OrganizationModel model : organizations) {
|
||||
OrganizationDomainModel bestMatch = getMatchingDomain(domain, model);
|
||||
|
||||
if (emailDomain == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Stream<OrganizationDomainModel> domains = organization.getDomains();
|
||||
|
||||
return domains.map(OrganizationDomainModel::getName).anyMatch(emailDomain::equals);
|
||||
}
|
||||
|
||||
private static OrganizationModel resolveOrganizationByDomain(UserModel user, String domain, OrganizationProvider provider) {
|
||||
if (user != null && domain == null) {
|
||||
domain = getEmailDomain(user);
|
||||
}
|
||||
|
||||
return ofNullable(domain)
|
||||
.map(provider::getByDomainName)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static Optional<OrganizationModel> resolveUserOrganization(List<OrganizationModel> organizations, UserModel user, String domain) {
|
||||
OrganizationModel orgByDomain = null;
|
||||
|
||||
for (OrganizationModel o : organizations) {
|
||||
if (o.isManaged(user)) {
|
||||
return of(o);
|
||||
if (bestMatch == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchesOrganizationDomain(o, user, domain)) {
|
||||
orgByDomain = o;
|
||||
if (organizations.size() == 1) {
|
||||
// only one organization, any domain match is enough
|
||||
return model;
|
||||
}
|
||||
|
||||
int mostSpecificParts = getDomainPartsSize(bestMatch.getName());
|
||||
|
||||
if (mostSpecificParts > bestParts) {
|
||||
bestParts = mostSpecificParts;
|
||||
organization = model;
|
||||
}
|
||||
}
|
||||
|
||||
return ofNullable(orgByDomain);
|
||||
return organization;
|
||||
}
|
||||
}
|
||||
|
||||
+20
-13
@@ -102,30 +102,37 @@ public class OrganizationMemberValidator extends AbstractSimpleValidator impleme
|
||||
AttributeContext attributeContext = upContext.getAttributeContext();
|
||||
UserModel user = attributeContext.getUser();
|
||||
String emailDomain = email.substring(email.indexOf('@') + 1);
|
||||
Set<String> expectedDomains = organization.getDomains().map(OrganizationDomainModel::getName).collect(Collectors.toSet());
|
||||
|
||||
if (expectedDomains.isEmpty()) {
|
||||
// no domain to check
|
||||
return;
|
||||
}
|
||||
|
||||
if (UserProfileContext.IDP_REVIEW.equals(attributeContext.getContext())) {
|
||||
expectedDomains = resolveExpectedDomainsWhenReviewingFederatedUserProfile(organization, attributeContext);
|
||||
Set<String> expectedDomains = resolveExpectedDomainsWhenReviewingFederatedUserProfile(organization, attributeContext);
|
||||
if (expectedDomains.isEmpty() || validateEmailDomainMatch(emailDomain, organization, expectedDomains)) {
|
||||
return;
|
||||
}
|
||||
} else if (organization.isManaged(user)) {
|
||||
expectedDomains = resolveExpectedDomainsForManagedUser(organization, context, user);
|
||||
Set<String> expectedDomains = resolveExpectedDomainsForManagedUser(organization, context, user);
|
||||
if (expectedDomains.isEmpty() || validateEmailDomainMatch(emailDomain, organization, expectedDomains)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// no validation happens for unmanaged users as they are realm users linked to an organization
|
||||
return;
|
||||
}
|
||||
|
||||
if (expectedDomains.isEmpty() || expectedDomains.contains(emailDomain)) {
|
||||
// valid email domain
|
||||
return;
|
||||
}
|
||||
|
||||
context.addError(new ValidationError(ID, inputHint, "Email domain does not match any domain from the organization"));
|
||||
}
|
||||
|
||||
private static boolean validateEmailDomainMatch(String emailDomain, OrganizationModel organization, Set<String> expectedDomains) {
|
||||
// Check if the email domain matches any expected domain with wildcard support
|
||||
for (String expectedDomain : expectedDomains) {
|
||||
String domain = ofNullable(Organizations.getMatchingDomain(emailDomain, organization)).map(OrganizationDomainModel::getName).orElse(null);
|
||||
|
||||
if (expectedDomain.equals(domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Set<String> resolveExpectedDomainsForManagedUser(OrganizationModel organization, ValidationContext context, UserModel user) {
|
||||
List<IdentityProviderModel> brokers = resolveHomeBroker(context.getSession(), user);
|
||||
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2026 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.utils;
|
||||
|
||||
import org.keycloak.models.OrganizationDomainModel;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Unit tests for domain matching logic including wildcard subdomain support with exclusion patterns.
|
||||
* Patterns: *.domain for wildcards, -.domain for exclusions, domain for exact matches.
|
||||
*
|
||||
* @author Keycloak Team
|
||||
*/
|
||||
public class OrganizationDomainMatchingTest {
|
||||
|
||||
@Test
|
||||
public void testExactDomainMatch() {
|
||||
OrganizationDomainModel domain = new OrganizationDomainModel("example.com", true);
|
||||
|
||||
// Exact match should work
|
||||
assertTrue(Organizations.isSameDomain("example.com", domain));
|
||||
|
||||
// Case insensitive
|
||||
assertTrue(Organizations.isSameDomain("Example.COM", domain));
|
||||
assertTrue(Organizations.isSameDomain("EXAMPLE.COM", domain));
|
||||
|
||||
// Subdomain should NOT match without wildcard
|
||||
assertFalse(Organizations.isSameDomain("sub.example.com", domain));
|
||||
assertFalse(Organizations.isSameDomain("deep.sub.example.com", domain));
|
||||
|
||||
// Different domain should not match
|
||||
assertFalse(Organizations.isSameDomain("other.com", domain));
|
||||
assertFalse(Organizations.isSameDomain("example.org", domain));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testWildcardSubdomainMatch() {
|
||||
OrganizationDomainModel domain = new OrganizationDomainModel("*.example.com", true);
|
||||
|
||||
// Exact match should still work
|
||||
assertTrue(Organizations.isSameDomain("example.com", domain));
|
||||
assertTrue(Organizations.isSameDomain("EXAMPLE.COM", domain));
|
||||
|
||||
// Subdomain should match with wildcard
|
||||
assertTrue(Organizations.isSameDomain("sub.example.com", domain));
|
||||
assertTrue(Organizations.isSameDomain("SUB.EXAMPLE.COM", domain));
|
||||
|
||||
// Deep subdomain should match
|
||||
assertTrue(Organizations.isSameDomain("deep.sub.example.com", domain));
|
||||
assertTrue(Organizations.isSameDomain("very.deep.sub.example.com", domain));
|
||||
|
||||
// Different domain should still not match
|
||||
assertFalse(Organizations.isSameDomain("other.com", domain));
|
||||
assertFalse(Organizations.isSameDomain("example.org", domain));
|
||||
|
||||
// Partial match should not work
|
||||
assertFalse(Organizations.isSameDomain("notexample.com", domain));
|
||||
assertFalse(Organizations.isSameDomain("example.com.fake", domain));
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testNullAndEmptyValues() {
|
||||
OrganizationDomainModel domain = new OrganizationDomainModel("*.example.com", true);
|
||||
|
||||
// Null email domain should not match
|
||||
assertFalse(Organizations.isSameDomain(null, domain));
|
||||
|
||||
// Empty string should not match
|
||||
assertFalse(Organizations.isSameDomain("", domain));
|
||||
|
||||
// Null organization domain should not match
|
||||
assertFalse(Organizations.isSameDomain("example.com", (OrganizationDomainModel) null));
|
||||
|
||||
// Null domain name should not match
|
||||
OrganizationDomainModel nullNameDomain = new OrganizationDomainModel(null, true);
|
||||
assertFalse(Organizations.isSameDomain("example.com", nullNameDomain));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEdgeCases() {
|
||||
OrganizationDomainModel domain = new OrganizationDomainModel("*.example.com", true);
|
||||
|
||||
// Single character subdomain
|
||||
assertTrue(Organizations.isSameDomain("a.example.com", domain));
|
||||
|
||||
// Numeric subdomain
|
||||
assertTrue(Organizations.isSameDomain("123.example.com", domain));
|
||||
|
||||
// Hyphenated subdomain
|
||||
assertTrue(Organizations.isSameDomain("my-app.example.com", domain));
|
||||
|
||||
// Mixed case with special subdomain
|
||||
assertTrue(Organizations.isSameDomain("My-App-123.Example.COM", domain));
|
||||
|
||||
// Should not match if there's extra content after
|
||||
assertFalse(Organizations.isSameDomain("example.com.fake", domain));
|
||||
}
|
||||
}
|
||||
+304
@@ -19,12 +19,18 @@ package org.keycloak.tests.organization.cache;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
|
||||
import org.keycloak.models.IdentityProviderStorageProvider;
|
||||
import org.keycloak.models.IdentityProviderStorageProvider.FetchMode;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.OrganizationDomainModel;
|
||||
import org.keycloak.models.OrganizationModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
@@ -33,6 +39,10 @@ import org.keycloak.models.cache.CacheRealmProvider;
|
||||
import org.keycloak.models.cache.infinispan.CachedCount;
|
||||
import org.keycloak.models.cache.infinispan.RealmCacheSession;
|
||||
import org.keycloak.models.cache.infinispan.idp.IdentityProviderListQuery;
|
||||
import org.keycloak.models.cache.infinispan.organization.CachedOrganization;
|
||||
import org.keycloak.models.cache.infinispan.organization.CachedOrganizationIds;
|
||||
import org.keycloak.models.cache.infinispan.organization.InfinispanOrganizationProvider;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.organization.OrganizationProvider;
|
||||
import org.keycloak.representations.idm.IdentityProviderRepresentation;
|
||||
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||
@@ -49,11 +59,13 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.keycloak.models.cache.infinispan.idp.InfinispanIdentityProviderStorageProvider.cacheKeyForLogin;
|
||||
import static org.keycloak.models.cache.infinispan.idp.InfinispanIdentityProviderStorageProvider.cacheKeyOrgId;
|
||||
import static org.keycloak.models.cache.infinispan.organization.CachedOrganization.DOMAIN_NAMES_CACHE_MAX_SIZE;
|
||||
import static org.keycloak.models.cache.infinispan.organization.InfinispanOrganizationProvider.cacheKeyOrgMemberCount;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@KeycloakIntegrationTest
|
||||
public class OrganizationCacheTest extends AbstractOrganizationTest {
|
||||
@@ -597,4 +609,296 @@ public class OrganizationCacheTest extends AbstractOrganizationTest {
|
||||
assertNotNull(org, "Mixed-case domain lookup should find the recreated organization");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetByWildcardDomain() {
|
||||
final String wildcardOrgAlias = "wildcard-org";
|
||||
final String wildcard = "*.wildcard.org";
|
||||
final String childA = "a.wildcard.org";
|
||||
final String childB = "deep.a.wildcard.org";
|
||||
|
||||
// 1. Create an organization owning "*.wildcard.org".
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.create(null, wildcardOrgAlias, wildcardOrgAlias);
|
||||
org.setDomains(Set.of(new OrganizationDomainModel(wildcard)));
|
||||
});
|
||||
|
||||
// 2. Resolve two distinct literal subdomains to populate the domain-lookup cache entries
|
||||
// under the wildcard org (both should resolve via the wildcard).
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.getByDomainName(childA);
|
||||
assertNotNull(org);
|
||||
assertEquals(wildcardOrgAlias, org.getAlias());
|
||||
org = orgProvider.getByDomainName(childB);
|
||||
assertNotNull(org);
|
||||
assertEquals(wildcardOrgAlias, org.getAlias());
|
||||
// Also the bare base domain must match the wildcard.
|
||||
org = orgProvider.getByDomainName("wildcard.org");
|
||||
assertNotNull(org);
|
||||
assertEquals(wildcardOrgAlias, org.getAlias());
|
||||
});
|
||||
|
||||
// 3. Replace the wildcard with an unrelated domain. The previously cached literal
|
||||
// lookups (childA, childB, "wildcard.org") must be invalidated so they stop
|
||||
// resolving to the wildcard org.
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.getByDomainName(wildcard);
|
||||
assertNotNull(org);
|
||||
org.setDomains(Set.of(new OrganizationDomainModel("unrelated.org")));
|
||||
});
|
||||
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
assertNull(orgProvider.getByDomainName(childA), "Cached child lookup must be invalidated after the wildcard is replaced");
|
||||
assertNull(orgProvider.getByDomainName(childB), "Cached nested-child lookup must be invalidated after the wildcard is replaced");
|
||||
assertNull(orgProvider.getByDomainName("wildcard.org"), "Cached base-domain lookup must be invalidated after the wildcard is replaced");
|
||||
// the new domain still resolves correctly
|
||||
OrganizationModel org = orgProvider.getByDomainName("unrelated.org");
|
||||
assertNotNull(org);
|
||||
assertEquals(wildcardOrgAlias, org.getAlias());
|
||||
});
|
||||
|
||||
// 4. Remove the org entirely and check that the last cached lookup ("unrelated.org")
|
||||
// is invalidated as well.
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.getByDomainName("unrelated.org");
|
||||
assertNotNull(org);
|
||||
orgProvider.remove(org);
|
||||
});
|
||||
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
assertNull(orgProvider.getByDomainName("unrelated.org"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExactDomainOverridesCachedWildcardMatch() {
|
||||
final String wildcardOrgAlias = "wildcard-org";
|
||||
final String exactOrgAlias = "exact-org";
|
||||
final String exactDomain = "team.precedence.org";
|
||||
|
||||
// Create a wildcard-owning org and resolve a literal subdomain through it so it lands in the cache.
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.create(null, wildcardOrgAlias, wildcardOrgAlias);
|
||||
org.setDomains(Set.of(new OrganizationDomainModel("*.precedence.org")));
|
||||
});
|
||||
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.getByDomainName(exactDomain);
|
||||
assertNotNull(org);
|
||||
assertEquals(wildcardOrgAlias, org.getAlias());
|
||||
});
|
||||
|
||||
// Create another org taking the exact domain. This must invalidate the previously cached
|
||||
// "team.precedence.org" -> wildcard-org entry so the lookup now returns the exact-domain org.
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.create(null, exactOrgAlias, exactOrgAlias);
|
||||
org.setDomains(Set.of(new OrganizationDomainModel(exactDomain)));
|
||||
});
|
||||
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.getByDomainName(exactDomain);
|
||||
assertNotNull(org);
|
||||
assertEquals(exactOrgAlias, org.getAlias(), "Exact domain must win over the previously cached wildcard resolution");
|
||||
|
||||
// sibling subdomain still resolves to the wildcard org
|
||||
OrganizationModel sibling = orgProvider.getByDomainName("sibling.precedence.org");
|
||||
assertNotNull(sibling);
|
||||
assertEquals(wildcardOrgAlias, sibling.getAlias());
|
||||
});
|
||||
|
||||
// Removing the exact-domain org must invalidate its cached lookup so the wildcard takes over again.
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.getByDomainName(exactDomain);
|
||||
assertNotNull(org);
|
||||
orgProvider.remove(org);
|
||||
});
|
||||
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.getByDomainName(exactDomain);
|
||||
assertNotNull(org);
|
||||
assertEquals(wildcardOrgAlias, org.getAlias(), "After removing the exact-domain org, resolution must fall back to the wildcard org");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBoundedDomainNamesInCache() {
|
||||
String wildcardDomain = "*.bounded.test.org";
|
||||
|
||||
// 1. Create an organization whose only configured domain is a wildcard so that all
|
||||
// sub*.bounded.test.org look-ups resolve to it without needing per-domain DB entries.
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.create(null, "bounded-org", "bounded-org");
|
||||
org.setDomains(Set.of(new OrganizationDomainModel(wildcardDomain)));
|
||||
});
|
||||
|
||||
// 2. Within ONE session, resolve MAX_DOMAIN_NAMES + 1 distinct sub-domains.
|
||||
// Each resolution causes addDomainName() to be called on the shared CachedOrganization,
|
||||
// which eventually triggers the LRU eviction and the cache-key invalidation.
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
for (int i = 0; i <= DOMAIN_NAMES_CACHE_MAX_SIZE; i++) {
|
||||
OrganizationModel org = orgProvider.getByDomainName("sub" + i + ".bounded.test.org");
|
||||
assertNotNull(org);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. In a fresh session verify the bounded-list invariants.
|
||||
runOnServer.run(session -> {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
RealmCacheSession realmCache = (RealmCacheSession) session.getProvider(CacheRealmProvider.class);
|
||||
|
||||
// sub0 was the second domain evicted (after *.bounded.test.org).
|
||||
// Its CachedOrganizationIds entry must have been invalidated and therefore absent.
|
||||
String evictedDomainCacheKey = InfinispanOrganizationProvider.cacheKeyByDomain(realm, "sub0.bounded.test.org");
|
||||
CachedOrganizationIds evictedCachedIds = realmCache.getCache().get(evictedDomainCacheKey, CachedOrganizationIds.class);
|
||||
assertNull(evictedCachedIds,
|
||||
"sub0.bounded.test.org was evicted from the bounded domainNames list; its cache entry must be gone");
|
||||
|
||||
// sub100 was the last domain added and must still be present in the cache.
|
||||
String lastDomainCacheKey = InfinispanOrganizationProvider.cacheKeyByDomain(realm, "sub100.bounded.test.org");
|
||||
CachedOrganizationIds lastCachedIds = realmCache.getCache().get(lastDomainCacheKey, CachedOrganizationIds.class);
|
||||
assertNotNull(lastCachedIds,
|
||||
"sub100.bounded.test.org was never evicted; its cache entry must still be present");
|
||||
|
||||
// The CachedOrganization's domainNames map must be bounded to exactly MAX_DOMAIN_NAMES entries
|
||||
// (sub1 … sub100) after the two evictions.
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.getByDomainName("sub100.bounded.test.org");
|
||||
assertNotNull(org);
|
||||
CachedOrganization cachedOrg = realmCache.getCache().get(org.getId(), CachedOrganization.class);
|
||||
assertNotNull(cachedOrg);
|
||||
assertEquals(DOMAIN_NAMES_CACHE_MAX_SIZE, cachedOrg.getDomainNames().size(),
|
||||
"The domainNames list in CachedOrganization must be bounded to " + DOMAIN_NAMES_CACHE_MAX_SIZE + " entries");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Concurrent variant of {@link #testBoundedDomainNamesInCache()}.
|
||||
*
|
||||
* <p>The {@code CachedOrganization.domainNames} map is a {@code Collections.synchronizedMap}-
|
||||
* wrapped {@code LinkedHashMap} with an LRU eviction policy that fires whenever the map
|
||||
* exceeds {@link CachedOrganization#DOMAIN_NAMES_CACHE_MAX_SIZE} entries. This test launches
|
||||
* {@code 2 × MAX_SIZE + 1} threads simultaneously, each running its own Keycloak transaction
|
||||
* and calling {@link OrganizationProvider#getByDomainName} with a unique sub-domain. All
|
||||
* threads are held behind a start-gate latch so they hit the shared {@code CachedOrganization}
|
||||
* at the same time, maximising contention on the synchronized map, the eviction callback and
|
||||
* the Infinispan cache operations.
|
||||
*
|
||||
* <p>The test verifies:
|
||||
* <ol>
|
||||
* <li>No exception (e.g. {@code ConcurrentModificationException}, deadlock timeout) is
|
||||
* thrown from any thread.</li>
|
||||
* <li>Every {@code getByDomainName} call returns a non-null result.</li>
|
||||
* <li>After all threads finish, {@code CachedOrganization.domainNames.size()} does not
|
||||
* exceed {@link CachedOrganization#DOMAIN_NAMES_CACHE_MAX_SIZE}.</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Test
|
||||
public void testBoundedDomainNamesInCacheConcurrent() {
|
||||
final String wildcardDomain = "*.concurrent.bounded.test.org";
|
||||
// Use 2×MAX+1 threads so that far more domains are submitted than the map can hold,
|
||||
// guaranteeing that evictions actually occur under concurrency.
|
||||
final int threadCount = DOMAIN_NAMES_CACHE_MAX_SIZE * 2 + 1;
|
||||
|
||||
// 1. Create an org with a wildcard domain so every sub*.concurrent.bounded.test.org
|
||||
// lookup resolves to it.
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
OrganizationModel org = orgProvider.create(null, "concurrent-bounded-org", "concurrent-bounded-org");
|
||||
org.setDomains(Set.of(new OrganizationDomainModel(wildcardDomain)));
|
||||
});
|
||||
|
||||
// 2. Warm-up: resolve one domain so the CachedOrganization is already stored in the
|
||||
// Infinispan cache before the concurrent calls start. All threads will then find and
|
||||
// share the same CachedOrganization object and call addDomainName() on it – the
|
||||
// scenario that exercises the synchronized LRU map under real concurrency.
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
assertNotNull(orgProvider.getByDomainName("warmup.concurrent.bounded.test.org"));
|
||||
});
|
||||
|
||||
// 3. Fire all threads at the same instant via a start-gate latch. Each thread runs its
|
||||
// own independent transaction through KeycloakModelUtils.runJobInTransaction so that
|
||||
// sessions do not share any per-session state (managedOrganizations map, invalidation
|
||||
// sets, etc.) while still operating on the same shared Infinispan CachedOrganization.
|
||||
runOnServer.run(session -> {
|
||||
KeycloakSessionFactory factory = session.getKeycloakSessionFactory();
|
||||
String realmId = session.getContext().getRealm().getId();
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch startGate = new CountDownLatch(1);
|
||||
CountDownLatch doneGate = new CountDownLatch(threadCount);
|
||||
AtomicReference<Throwable> firstError = new AtomicReference<>();
|
||||
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
final String domain = "sub" + i + ".concurrent.bounded.test.org";
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
startGate.await(); // hold until all threads are ready
|
||||
KeycloakModelUtils.runJobInTransaction(factory, s -> {
|
||||
s.getContext().setRealm(s.realms().getRealm(realmId));
|
||||
OrganizationProvider op = s.getProvider(OrganizationProvider.class);
|
||||
assertNotNull(op.getByDomainName(domain),
|
||||
"getByDomainName must resolve the org for domain: " + domain);
|
||||
});
|
||||
} catch (Throwable t) {
|
||||
firstError.compareAndSet(null, t);
|
||||
} finally {
|
||||
doneGate.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startGate.countDown(); // release all threads simultaneously
|
||||
|
||||
try {
|
||||
if (!doneGate.await(60, TimeUnit.SECONDS)) {
|
||||
throw new RuntimeException("Timed out waiting for concurrent domain lookups to finish");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("Interrupted while waiting for concurrent domain lookups", e);
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
Throwable error = firstError.get();
|
||||
|
||||
if (error != null) {
|
||||
throw new RuntimeException("A concurrent domain lookup thread failed", error);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. In a fresh session verify the bounded-map invariant.
|
||||
// We obtain the org via getAllStream() – which calls getById() on a cache hit but does
|
||||
// NOT call addDomainName() – then inspect the CachedOrganization directly from the
|
||||
// Infinispan cache to avoid disturbing the domainNames map further.
|
||||
runOnServer.run(session -> {
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
RealmCacheSession realmCache = (RealmCacheSession) session.getProvider(CacheRealmProvider.class);
|
||||
OrganizationModel org = orgProvider.getAllStream("concurrent-bounded-org", true, null, null)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("concurrent-bounded-org not found"));
|
||||
CachedOrganization cachedOrg = realmCache.getCache().get(org.getId(), CachedOrganization.class);
|
||||
assertNotNull(cachedOrg, "CachedOrganization must still be present after concurrent lookups");
|
||||
|
||||
int size = cachedOrg.getDomainNames().size();
|
||||
assertTrue(size <= DOMAIN_NAMES_CACHE_MAX_SIZE,
|
||||
"domainNames size " + size + " exceeded the bound of " + DOMAIN_NAMES_CACHE_MAX_SIZE
|
||||
+ " under concurrent access – the LRU eviction is not thread-safe");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+506
-6
@@ -34,10 +34,12 @@ import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
|
||||
import org.keycloak.admin.client.resource.OrganizationIdentityProviderResource;
|
||||
import org.keycloak.admin.client.resource.OrganizationResource;
|
||||
import org.keycloak.admin.client.resource.OrganizationsResource;
|
||||
import org.keycloak.admin.client.resource.RealmResource;
|
||||
import org.keycloak.models.OrganizationModel;
|
||||
import org.keycloak.models.OrganizationModel.IdentityProviderRedirectMode;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.organization.OrganizationProvider;
|
||||
import org.keycloak.representations.idm.ErrorRepresentation;
|
||||
@@ -50,6 +52,7 @@ import org.keycloak.testsuite.admin.ApiUtil;
|
||||
import org.keycloak.testsuite.runonserver.RunOnServer;
|
||||
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
|
||||
@@ -73,6 +76,13 @@ import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
public class OrganizationTest extends AbstractOrganizationTest {
|
||||
|
||||
@Before
|
||||
public void onBefore() {
|
||||
for (OrganizationRepresentation org : managedRealm.admin().organizations().list(null, null)) {
|
||||
managedRealm.admin().organizations().get(org.getId()).delete().close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUpdate() {
|
||||
OrganizationRepresentation expected = createOrganization();
|
||||
@@ -470,6 +480,61 @@ public class OrganizationTest extends AbstractOrganizationTest {
|
||||
assertNotNull(existing.getDomain("acme.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDomainWithWildcardSubdomains() {
|
||||
// Create organization with a domain that has wildcard subdomain matching enabled
|
||||
OrganizationRepresentation org = createOrganization();
|
||||
OrganizationResource organization = managedRealm.admin().organizations().get(org.getId());
|
||||
|
||||
// Add a domain with wildcard subdomain matching using *.domain pattern
|
||||
OrganizationDomainRepresentation wildcardDomain = new OrganizationDomainRepresentation();
|
||||
wildcardDomain.setName("*.example.com");
|
||||
wildcardDomain.setVerified(true);
|
||||
org.addDomain(wildcardDomain);
|
||||
|
||||
try (Response response = organization.update(org)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Verify the wildcard domain was saved correctly
|
||||
OrganizationRepresentation updated = organization.toRepresentation();
|
||||
assertEquals(2, updated.getDomains().size());
|
||||
|
||||
OrganizationDomainRepresentation savedDomain = updated.getDomain("*.example.com");
|
||||
assertNotNull(savedDomain);
|
||||
assertTrue(savedDomain.isVerified());
|
||||
|
||||
// Verify that the original domain without wildcard remains unchanged
|
||||
OrganizationDomainRepresentation defaultDomain = updated.getDomain("neworg.org");
|
||||
assertNotNull(defaultDomain);
|
||||
|
||||
// Test changing to exact match (removing wildcard prefix)
|
||||
org.getDomains().remove(wildcardDomain);
|
||||
OrganizationDomainRepresentation exactDomain = new OrganizationDomainRepresentation();
|
||||
exactDomain.setName("example.com");
|
||||
exactDomain.setVerified(true);
|
||||
org.addDomain(exactDomain);
|
||||
|
||||
try (Response response = organization.update(org)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
updated = organization.toRepresentation();
|
||||
assertNotNull(updated.getDomain("example.com"));
|
||||
assertNull(updated.getDomain("*.example.com"));
|
||||
|
||||
// Re-add wildcard domain
|
||||
org.getDomains().remove(exactDomain);
|
||||
org.addDomain(wildcardDomain);
|
||||
try (Response response = organization.update(org)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
updated = organization.toRepresentation();
|
||||
savedDomain = updated.getDomain("*.example.com");
|
||||
assertNotNull(savedDomain);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testWithoutDomains() {
|
||||
// test create organization without any domains
|
||||
@@ -533,12 +598,6 @@ public class OrganizationTest extends AbstractOrganizationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFilterEmptyDomain() {
|
||||
//org should be created with only one domain
|
||||
assertThat(createOrganization("singleValidDomainOrg", "validDomain.com", "", null).getDomains(), hasSize(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDisabledOrganizationProvider() throws IOException {
|
||||
OrganizationRepresentation existing = createOrganization("acme", "acme.org", "acme.net");
|
||||
@@ -731,4 +790,445 @@ public class OrganizationTest extends AbstractOrganizationTest {
|
||||
assertThat(organization.toRepresentation().getRedirectUrl(), nullValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDomainConflictExactDuplicate() {
|
||||
// Org A owns "example.com"; Org B must not be able to claim the same domain.
|
||||
createOrganization("org-a", "example.com");
|
||||
|
||||
OrganizationRepresentation orgB = createOrganization("org-b");
|
||||
OrganizationDomainRepresentation duplicateDomain = new OrganizationDomainRepresentation();
|
||||
duplicateDomain.setName("example.com");
|
||||
orgB.addDomain(duplicateDomain);
|
||||
|
||||
try (Response response = managedRealm.admin().organizations().get(orgB.getId()).update(orgB)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation orgC = createRepresentation("org-c", "example.com");
|
||||
try (Response response = managedRealm.admin().organizations().create(orgC)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDomainAllowExactAndWildcardDomainWithSameBaseDomain() {
|
||||
createOrganization("org-a", "example.com");
|
||||
|
||||
OrganizationRepresentation orgB = createOrganization("org-b");
|
||||
OrganizationDomainRepresentation wildcardDomain = new OrganizationDomainRepresentation();
|
||||
wildcardDomain.setName("*.example.com");
|
||||
orgB.addDomain(wildcardDomain);
|
||||
|
||||
try (Response response = managedRealm.admin().organizations().get(orgB.getId()).update(orgB)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAllowSubDomainsAndWildcardDomainInSeparateOrgs() {
|
||||
OrganizationRepresentation orgA = createOrganization("org-a", "acme.org");
|
||||
OrganizationDomainRepresentation wildcardDomain = new OrganizationDomainRepresentation();
|
||||
wildcardDomain.setName("*.example.com");
|
||||
orgA.addDomain(wildcardDomain);
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(orgA)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation orgB = createOrganization("org-b");
|
||||
OrganizationDomainRepresentation subDomain = new OrganizationDomainRepresentation();
|
||||
subDomain.setName("sub.example.com");
|
||||
orgB.addDomain(subDomain);
|
||||
|
||||
try (Response response = managedRealm.admin().organizations().get(orgB.getId()).update(orgB)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
orgB = managedRealm.admin().organizations().get(orgB.getId()).toRepresentation();
|
||||
OrganizationDomainRepresentation deepSubDomain = new OrganizationDomainRepresentation();
|
||||
deepSubDomain.setName("deep.sub.example.com");
|
||||
orgB.addDomain(deepSubDomain);
|
||||
|
||||
try (Response response = managedRealm.admin().organizations().get(orgB.getId()).update(orgB)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation orgC = createOrganization("org-c");
|
||||
subDomain = new OrganizationDomainRepresentation();
|
||||
subDomain.setName("*.deep.sub.example.com");
|
||||
orgC.addDomain(subDomain);
|
||||
|
||||
try (Response response = managedRealm.admin().organizations().get(orgC.getId()).update(orgC)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation orgD = createOrganization("org-d");
|
||||
subDomain = new OrganizationDomainRepresentation();
|
||||
subDomain.setName("some.deep.sub.example.com");
|
||||
orgD.addDomain(subDomain);
|
||||
|
||||
try (Response response = managedRealm.admin().organizations().get(orgD.getId()).update(orgD)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAddingOrgWithWildCardDomainWhenExactDomainOrgExist() {
|
||||
createOrganization("org-a", "sub.example.com");
|
||||
createOrganization("org-b", "test.sub.example.com");
|
||||
|
||||
OrganizationRepresentation orgC = createOrganization("org-c");
|
||||
OrganizationDomainRepresentation wildcardDomain = new OrganizationDomainRepresentation();
|
||||
wildcardDomain.setName("*.example.com");
|
||||
orgC.addDomain(wildcardDomain);
|
||||
|
||||
try (Response response = managedRealm.admin().organizations().get(orgC.getId()).update(orgC)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation orgD = createOrganization("org-d");
|
||||
orgD.addDomain(new OrganizationDomainRepresentation("*.sub.example.com"));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgD.getId()).update(orgD)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation orgE = createOrganization("org-e");
|
||||
orgE.addDomain(new OrganizationDomainRepresentation("sub.example.com"));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgE.getId()).update(orgE)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDomainConflictDuplicateWildcard() {
|
||||
// Org A owns "*.example.com". Org B must not also claim "*.example.com".
|
||||
OrganizationRepresentation orgA = createOrganization("org-a", "acme.org");
|
||||
OrganizationDomainRepresentation wildcardDomain = new OrganizationDomainRepresentation();
|
||||
wildcardDomain.setName("*.example.com");
|
||||
orgA.addDomain(wildcardDomain);
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(orgA)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation orgB = createOrganization("org-b");
|
||||
OrganizationDomainRepresentation duplicateWildcard = new OrganizationDomainRepresentation();
|
||||
duplicateWildcard.setName("*.example.com");
|
||||
orgB.addDomain(duplicateWildcard);
|
||||
|
||||
try (Response response = managedRealm.admin().organizations().get(orgB.getId()).update(orgB)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDomainNoConflictDifferentBaseDomains() {
|
||||
// Org A owns "*.example.com" and Org B owns "*.acme.com" — no overlap, both should succeed.
|
||||
OrganizationRepresentation orgA = createOrganization("org-a", "other-a.org");
|
||||
OrganizationDomainRepresentation wildcardA = new OrganizationDomainRepresentation();
|
||||
wildcardA.setName("*.example.com");
|
||||
orgA.addDomain(wildcardA);
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(orgA)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation orgB = createOrganization("org-b", "other-b.org");
|
||||
OrganizationDomainRepresentation wildcardB = new OrganizationDomainRepresentation();
|
||||
wildcardB.setName("*.acme.com");
|
||||
orgB.addDomain(wildcardB);
|
||||
try (Response response = managedRealm.admin().organizations().get(orgB.getId()).update(orgB)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation updatedA = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
assertNotNull(updatedA.getDomain("*.example.com"));
|
||||
|
||||
OrganizationRepresentation updatedB = managedRealm.admin().organizations().get(orgB.getId()).toRepresentation();
|
||||
assertNotNull(updatedB.getDomain("*.acme.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDomainNoConflictUnrelatedExactDomains() {
|
||||
// Org A owns "example.com", Org B owns "acme.com" — no conflict.
|
||||
OrganizationRepresentation orgA = createOrganization("org-a", "example.com");
|
||||
OrganizationRepresentation orgB = createOrganization("org-b", "acme.com");
|
||||
|
||||
OrganizationRepresentation updatedA = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
assertNotNull(updatedA.getDomain("example.com"));
|
||||
|
||||
OrganizationRepresentation updatedB = managedRealm.admin().organizations().get(orgB.getId()).toRepresentation();
|
||||
assertNotNull(updatedB.getDomain("acme.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDomainConflictWildcardOnCreate() {
|
||||
// Org A owns "*.example.com". Creating Org B with "sub.example.com" must fail.
|
||||
OrganizationRepresentation orgA = createOrganization("org-a", "acme.org");
|
||||
OrganizationDomainRepresentation wildcardDomain = new OrganizationDomainRepresentation();
|
||||
wildcardDomain.setName("*.example.com");
|
||||
orgA.addDomain(wildcardDomain);
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(orgA)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Creating org with subdomain of the wildcard should fail
|
||||
OrganizationRepresentation orgB = createRepresentation("org-b", "sub.example.com");
|
||||
try (Response response = managedRealm.admin().organizations().create(orgB)) {
|
||||
assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Creating org with the exact base domain should also fail
|
||||
OrganizationRepresentation orgC = createRepresentation("org-c", "example.com");
|
||||
try (Response response = managedRealm.admin().organizations().create(orgC)) {
|
||||
assertEquals(Status.CREATED.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Creating org with the same wildcard should fail
|
||||
OrganizationRepresentation orgD = createRepresentation("org-d", "*.example.com");
|
||||
try (Response response = managedRealm.admin().organizations().create(orgD)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testResolveOrganizationByDomain() {
|
||||
OrganizationRepresentation orgA = createOrganization("org-a", "sub.example.com");
|
||||
OrganizationResource organization = managedRealm.admin().organizations().get(orgA.getId());
|
||||
OrganizationIdentityProviderResource broker = organization.identityProviders().get(brokerConfigFunction.apply(orgA.getAlias()).getIDPAlias());
|
||||
IdentityProviderRepresentation brokerRepOrgA = broker.toRepresentation();
|
||||
brokerRepOrgA.setHideOnLogin(false);
|
||||
brokerRepOrgA.getConfig().remove(IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
|
||||
managedRealm.admin().identityProviders().get(brokerRepOrgA.getAlias()).update(brokerRepOrgA);
|
||||
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgA.getAlias()));
|
||||
|
||||
OrganizationRepresentation orgB = createOrganization("org-b", "example.com");
|
||||
organization = managedRealm.admin().organizations().get(orgB.getId());
|
||||
broker = organization.identityProviders().get(brokerConfigFunction.apply(orgB.getAlias()).getIDPAlias());
|
||||
IdentityProviderRepresentation brokerRepOrgB = broker.toRepresentation();
|
||||
brokerRepOrgB.setHideOnLogin(false);
|
||||
brokerRepOrgB.getConfig().remove(IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
|
||||
managedRealm.admin().identityProviders().get(brokerRepOrgB.getAlias()).update(brokerRepOrgB);
|
||||
oauth.client("broker-app");
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgA.getAlias()));
|
||||
|
||||
OrganizationRepresentation orgC = createOrganization("org-c", "*.deep.sub.example.com");
|
||||
organization = managedRealm.admin().organizations().get(orgC.getId());
|
||||
broker = organization.identityProviders().get(brokerConfigFunction.apply(orgC.getAlias()).getIDPAlias());
|
||||
IdentityProviderRepresentation brokerRepOrgC = broker.toRepresentation();
|
||||
brokerRepOrgC.setHideOnLogin(false);
|
||||
brokerRepOrgC.getConfig().remove(IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
|
||||
managedRealm.admin().identityProviders().get(brokerRepOrgC.getAlias()).update(brokerRepOrgC);
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@deep.sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgC.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgA.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDomainConflictAfterDeletingOrganization() {
|
||||
OrganizationRepresentation orgA = createOrganization("org-a", "acme.org");
|
||||
OrganizationDomainRepresentation wildcardDomain = new OrganizationDomainRepresentation();
|
||||
wildcardDomain.setName("*.example.com");
|
||||
orgA.addDomain(wildcardDomain);
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(orgA)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Org B cannot claim "example.com" while Org A exists
|
||||
OrganizationRepresentation orgB = createOrganization("org-b");
|
||||
OrganizationDomainRepresentation exactDomain = new OrganizationDomainRepresentation();
|
||||
exactDomain.setName("example.com");
|
||||
orgB.addDomain(exactDomain);
|
||||
try (Response response = managedRealm.admin().organizations().get(orgB.getId()).update(orgB)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Delete Org A
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).delete()) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Now Org B should be able to claim "example.com"
|
||||
orgB = managedRealm.admin().organizations().get(orgB.getId()).toRepresentation();
|
||||
exactDomain = new OrganizationDomainRepresentation();
|
||||
exactDomain.setName("example.com");
|
||||
orgB.addDomain(exactDomain);
|
||||
try (Response response = managedRealm.admin().organizations().get(orgB.getId()).update(orgB)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation updatedB = managedRealm.admin().organizations().get(orgB.getId()).toRepresentation();
|
||||
assertNotNull(updatedB.getDomain("example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDomainConflictAfterRemovingDomain() {
|
||||
// Org A owns "example.com". After removing that domain, Org B should be able to claim it.
|
||||
OrganizationRepresentation orgA = createOrganization("org-a", "example.com");
|
||||
OrganizationRepresentation orgB = createOrganization("org-b");
|
||||
|
||||
// Org B cannot claim "example.com" while Org A has it
|
||||
OrganizationDomainRepresentation exactDomain = new OrganizationDomainRepresentation();
|
||||
exactDomain.setName("example.com");
|
||||
orgB.addDomain(exactDomain);
|
||||
try (Response response = managedRealm.admin().organizations().get(orgB.getId()).update(orgB)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Remove "example.com" from Org A
|
||||
orgA = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
OrganizationDomainRepresentation domainToRemove = orgA.getDomain("example.com");
|
||||
assertNotNull(domainToRemove);
|
||||
orgA.removeDomain(domainToRemove);
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(orgA)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Now Org B should be able to claim "example.com"
|
||||
orgB = managedRealm.admin().organizations().get(orgB.getId()).toRepresentation();
|
||||
exactDomain = new OrganizationDomainRepresentation();
|
||||
exactDomain.setName("example.com");
|
||||
orgB.addDomain(exactDomain);
|
||||
try (Response response = managedRealm.admin().organizations().get(orgB.getId()).update(orgB)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation updatedB = managedRealm.admin().organizations().get(orgB.getId()).toRepresentation();
|
||||
assertNotNull(updatedB.getDomain("example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDomainSameOrgCanUpdateOwnDomains() {
|
||||
// An organization should be able to update its own domains without self-conflict.
|
||||
OrganizationRepresentation orgA = createOrganization("org-a", "example.com");
|
||||
OrganizationResource organization = managedRealm.admin().organizations().get(orgA.getId());
|
||||
|
||||
// Re-submit the same domains — should succeed (no self-conflict)
|
||||
orgA = organization.toRepresentation();
|
||||
try (Response response = organization.update(orgA)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Add a wildcard domain to the same org — should succeed
|
||||
OrganizationDomainRepresentation wildcardDomain = new OrganizationDomainRepresentation();
|
||||
wildcardDomain.setName("*.example.com");
|
||||
orgA.addDomain(wildcardDomain);
|
||||
try (Response response = organization.update(orgA)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
OrganizationRepresentation updated = organization.toRepresentation();
|
||||
assertNotNull(updated.getDomain("example.com"));
|
||||
assertNotNull(updated.getDomain("*.example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRejectDomainWithTooManyParts() {
|
||||
// 11-part domain (10 dots) must be rejected by Organizations.validateDomainParts.
|
||||
String tooLong = "a.b.c.d.e.f.g.h.i.j.com";
|
||||
OrganizationRepresentation orgA = createOrganization("org-a");
|
||||
orgA.addDomain(new OrganizationDomainRepresentation(tooLong));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(orgA)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// the wildcard form of the same over-long domain must also be rejected
|
||||
orgA = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
orgA.addDomain(new OrganizationDomainRepresentation("*." + tooLong));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(orgA)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// a 10-part domain (9 dots) is still allowed
|
||||
String atLimit = "*.a.b.c.d.e.f.g.h.i.jay";
|
||||
orgA = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
orgA.addDomain(new OrganizationDomainRepresentation(atLimit));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(orgA)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRejectSinglePartDomains() {
|
||||
OrganizationRepresentation orgA = createOrganization("org-a");
|
||||
|
||||
// a wildcard over a bare TLD must also be rejected
|
||||
for (String wildcardTld : List.of("*.com", "*.org", "*.net", "*.io")) {
|
||||
OrganizationRepresentation attempt = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
attempt.addDomain(new OrganizationDomainRepresentation(wildcardTld));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(attempt)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
// the minimum valid values (2-part base) must be accepted
|
||||
for (String valid : List.of("example.com", "*.example.com")) {
|
||||
OrganizationRepresentation attempt = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
attempt.addDomain(new OrganizationDomainRepresentation(valid));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(attempt)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRejectInvalidWildcardPatterns() {
|
||||
OrganizationRepresentation orgA = createOrganization("org-a", "example.com");
|
||||
|
||||
// bare "*." (no base domain) is rejected
|
||||
OrganizationRepresentation attempt = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
attempt.addDomain(new OrganizationDomainRepresentation("*."));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(attempt)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// wildcard in the middle of the pattern is rejected
|
||||
attempt = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
attempt.addDomain(new OrganizationDomainRepresentation("sub.*.example.com"));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(attempt)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// multiple wildcards are rejected
|
||||
attempt = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
attempt.addDomain(new OrganizationDomainRepresentation("*.*.example.com"));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(attempt)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// an empty domain name is rejected
|
||||
attempt = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
attempt.addDomain(new OrganizationDomainRepresentation(""));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(attempt)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// a syntactically invalid base domain is rejected
|
||||
attempt = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
attempt.addDomain(new OrganizationDomainRepresentation("*.not valid"));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(attempt)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// a wildcard over a bare TLD is rejected (base domain has only 1 part)
|
||||
attempt = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
attempt.addDomain(new OrganizationDomainRepresentation("*.com"));
|
||||
try (Response response = managedRealm.admin().organizations().get(orgA.getId()).update(attempt)) {
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// sanity: none of the rejected values were persisted
|
||||
OrganizationRepresentation reloaded = managedRealm.admin().organizations().get(orgA.getId()).toRepresentation();
|
||||
assertEquals(1, reloaded.getDomains().size());
|
||||
assertNotNull(reloaded.getDomain("example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
+300
@@ -865,6 +865,166 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz
|
||||
Assertions.assertTrue(loginPage.isSocialButtonPresent(bc.getIDPAlias()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRedirectToIdentityProviderWithWildcardSubdomainMatching() {
|
||||
OrganizationRepresentation orgRep = createOrganization();
|
||||
OrganizationResource organization = managedRealm.admin().organizations().get(orgRep.getId());
|
||||
|
||||
// Update the organization domain to enable wildcard subdomain matching
|
||||
OrganizationDomainRepresentation domain = orgRep.getDomains().iterator().next();
|
||||
domain.setName("*." + domain.getName());
|
||||
try (Response response = organization.update(orgRep)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Configure IdP with ANY_DOMAIN to match any org domain
|
||||
IdentityProviderRepresentation idp = organization.identityProviders().get(bc.getIDPAlias()).toRepresentation();
|
||||
idp.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, ANY_DOMAIN);
|
||||
idp.getConfig().put(IdentityProviderRedirectMode.EMAIL_MATCH.getKey(), Boolean.TRUE.toString());
|
||||
managedRealm.admin().identityProviders().get(bc.getIDPAlias()).update(idp);
|
||||
|
||||
// Test with subdomain - should automatically redirect
|
||||
String subdomainEmail = "user@sub.neworg.org";
|
||||
openIdentityFirstLoginPage(subdomainEmail, true, idp.getAlias(), false, false);
|
||||
MatcherAssert.assertThat("Driver should be on the provider realm page right now",
|
||||
driver.getCurrentUrl(), Matchers.containsString("/auth/realms/" + bc.providerRealmName() + "/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRedirectToIdentityProviderWithDeepSubdomain() {
|
||||
OrganizationRepresentation orgRep = createOrganization();
|
||||
OrganizationResource organization = managedRealm.admin().organizations().get(orgRep.getId());
|
||||
String deepSubdomainEmail = "user@deep.sub.neworg.org";
|
||||
IdentityProviderRepresentation idp = organization.identityProviders().get(bc.getIDPAlias()).toRepresentation();
|
||||
|
||||
openIdentityFirstLoginPage(deepSubdomainEmail, false, idp.getAlias(), false, false);
|
||||
MatcherAssert.assertThat("Driver should be on the consumer realm page right now",
|
||||
driver.getCurrentUrl(), Matchers.containsString("/auth/realms/" + bc.consumerRealmName() + "/"));
|
||||
|
||||
// Enable wildcard subdomain matching
|
||||
OrganizationDomainRepresentation domain = orgRep.getDomains().iterator().next();
|
||||
String baseDomain = domain.getName();
|
||||
|
||||
domain.setName("*." + baseDomain);
|
||||
|
||||
try (Response response = organization.update(orgRep)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
idp.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, ANY_DOMAIN);
|
||||
idp.getConfig().put(IdentityProviderRedirectMode.EMAIL_MATCH.getKey(), Boolean.TRUE.toString());
|
||||
managedRealm.admin().identityProviders().get(bc.getIDPAlias()).update(idp);
|
||||
|
||||
// Test with deep subdomain (multiple levels)
|
||||
openIdentityFirstLoginPage(deepSubdomainEmail, true, idp.getAlias(), false, false);
|
||||
// user should be automatically redirected to the org IdP login page
|
||||
MatcherAssert.assertThat("Driver should be on the provider realm page right now",
|
||||
driver.getCurrentUrl(), Matchers.containsString("/auth/realms/" + bc.providerRealmName() + "/"));
|
||||
|
||||
orgRep.addDomain(new OrganizationDomainRepresentation("deep.sub." + baseDomain));
|
||||
|
||||
try (Response response = organization.update(orgRep)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
// Test with deep subdomain (multiple levels)
|
||||
openIdentityFirstLoginPage(deepSubdomainEmail, true, idp.getAlias(), false, false);
|
||||
// user should be automatically redirected to the org IdP login page
|
||||
MatcherAssert.assertThat("Driver should be on the provider realm page right now",
|
||||
driver.getCurrentUrl(), Matchers.containsString("/auth/realms/" + bc.providerRealmName() + "/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNoRedirectWithoutWildcardSubdomainMatching() {
|
||||
OrganizationRepresentation orgRep = createOrganization();
|
||||
OrganizationResource organization = managedRealm.admin().organizations().get(orgRep.getId());
|
||||
|
||||
// Ensure domain doesn't have wildcard prefix (exact match only)
|
||||
OrganizationDomainRepresentation domain = orgRep.getDomains().iterator().next();
|
||||
if (domain.getName().startsWith("*.")) {
|
||||
domain.setName(domain.getName().substring(2));
|
||||
}
|
||||
try (Response response = organization.update(orgRep)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
IdentityProviderRepresentation idp = organization.identityProviders().get(bc.getIDPAlias()).toRepresentation();
|
||||
idp.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, ANY_DOMAIN);
|
||||
idp.getConfig().put(IdentityProviderRedirectMode.EMAIL_MATCH.getKey(), Boolean.TRUE.toString());
|
||||
managedRealm.admin().identityProviders().get(bc.getIDPAlias()).update(idp);
|
||||
|
||||
// Test with subdomain - should NOT automatically redirect since wildcard is disabled
|
||||
// The subdomain email doesn't match the exact domain, so no redirect should occur
|
||||
String subdomainEmail = "user@sub.neworg.org";
|
||||
|
||||
// With exact match only, subdomain won't match, so user sees standard login
|
||||
openIdentityFirstLoginPage(subdomainEmail, false, null, false, false);
|
||||
|
||||
// Verify we're on the login page (no automatic redirect happened)
|
||||
assertTrue(driver.getCurrentUrl().contains("/realms/" + bc.consumerRealmName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExactDomainStillWorksWithWildcardEnabled() {
|
||||
OrganizationRepresentation orgRep = createOrganization();
|
||||
OrganizationResource organization = managedRealm.admin().organizations().get(orgRep.getId());
|
||||
|
||||
// Enable wildcard subdomain matching
|
||||
OrganizationDomainRepresentation domain = orgRep.getDomains().iterator().next();
|
||||
domain.setName("*." + domain.getName());
|
||||
try (Response response = organization.update(orgRep)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
IdentityProviderRepresentation idp = organization.identityProviders().get(bc.getIDPAlias()).toRepresentation();
|
||||
idp.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, domain.getName());
|
||||
idp.getConfig().put(IdentityProviderRedirectMode.EMAIL_MATCH.getKey(), Boolean.TRUE.toString());
|
||||
managedRealm.admin().identityProviders().get(bc.getIDPAlias()).update(idp);
|
||||
|
||||
// Test with exact domain match - should still work
|
||||
openIdentityFirstLoginPage(bc.getUserEmail(), true, idp.getAlias(), false, false);
|
||||
|
||||
loginOrgIdp(bc.getUserEmail(), bc.getUserEmail(), true, true);
|
||||
|
||||
assertIsMember(bc.getUserEmail(), organization);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDoNotRedirectIfExclusionDomain() {
|
||||
OrganizationRepresentation orgRep = createOrganization();
|
||||
OrganizationResource organization = managedRealm.admin().organizations().get(orgRep.getId());
|
||||
|
||||
// Update the organization domain to enable wildcard subdomain matching
|
||||
OrganizationDomainRepresentation domain = orgRep.getDomains().iterator().next();
|
||||
String domainName = domain.getName();
|
||||
domain.setName("*." + domainName);
|
||||
try (Response response = organization.update(orgRep)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
organization.update(orgRep).close();
|
||||
|
||||
// Configure IdP with ANY_DOMAIN to match any org domain
|
||||
IdentityProviderRepresentation idp = organization.identityProviders().get(bc.getIDPAlias()).toRepresentation();
|
||||
idp.getConfig().put(OrganizationModel.ORGANIZATION_DOMAIN_ATTRIBUTE, ANY_DOMAIN);
|
||||
idp.getConfig().put(OrganizationModel.ORGANIZATION_EXCLUDED_DOMAIN_ATTRIBUTE, "*.sub." + domainName);
|
||||
idp.getConfig().put(IdentityProviderRedirectMode.EMAIL_MATCH.getKey(), Boolean.TRUE.toString());
|
||||
managedRealm.admin().identityProviders().get(bc.getIDPAlias()).update(idp);
|
||||
|
||||
// Test with subdomain - should not automatically redirect
|
||||
String subdomainEmail = "user@sub.neworg.org";
|
||||
openIdentityFirstLoginPage(subdomainEmail, false, idp.getAlias(), false, false);
|
||||
MatcherAssert.assertThat("Driver should be on the consumer realm page right now",
|
||||
driver.getCurrentUrl(), Matchers.containsString("/auth/realms/" + bc.consumerRealmName() + "/"));
|
||||
|
||||
openIdentityFirstLoginPage("user@valid.neworg.org", true, idp.getAlias(), false, false);
|
||||
MatcherAssert.assertThat("Driver should be on the provider realm page right now",
|
||||
driver.getCurrentUrl(), Matchers.containsString("/auth/realms/" + bc.providerRealmName() + "/"));
|
||||
openIdentityFirstLoginPage("user@neworg.org", true, idp.getAlias(), false, false);
|
||||
MatcherAssert.assertThat("Driver should be on the provider realm page right now",
|
||||
driver.getCurrentUrl(), Matchers.containsString("/auth/realms/" + bc.providerRealmName() + "/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOnlyShowBrokersAssociatedWithResolvedOrganization() {
|
||||
String org0Name = "org-0";
|
||||
@@ -1004,6 +1164,7 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz
|
||||
assertIsMember("external@other.org", organization);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testAnyEmailFromBrokerWithoutDomainSet() {
|
||||
OrganizationResource organization = managedRealm.admin().organizations().get(createOrganization().getId());
|
||||
@@ -1196,6 +1357,145 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz
|
||||
assertTrue(loginPage.isSocialButtonPresent(org2Idp.getAlias()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExactDomainPrecedenceWhenResolvingOrganizationByDomain() {
|
||||
OrganizationRepresentation orgA = createOrganization("org-a", "sub.example.com");
|
||||
OrganizationResource organization = managedRealm.admin().organizations().get(orgA.getId());
|
||||
OrganizationIdentityProviderResource broker = organization.identityProviders().get(brokerConfigFunction.apply(orgA.getAlias()).getIDPAlias());
|
||||
IdentityProviderRepresentation brokerRepOrgA = broker.toRepresentation();
|
||||
brokerRepOrgA.setHideOnLogin(false);
|
||||
brokerRepOrgA.getConfig().remove(IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
|
||||
managedRealm.admin().identityProviders().get(brokerRepOrgA.getAlias()).update(brokerRepOrgA);
|
||||
|
||||
OrganizationRepresentation orgB = createOrganization("org-b");
|
||||
organization = managedRealm.admin().organizations().get(orgB.getId());
|
||||
broker = organization.identityProviders().get(brokerConfigFunction.apply(orgB.getAlias()).getIDPAlias());
|
||||
IdentityProviderRepresentation brokerRepOrgB = broker.toRepresentation();
|
||||
brokerRepOrgB.setHideOnLogin(false);
|
||||
brokerRepOrgB.getConfig().remove(IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
|
||||
managedRealm.admin().identityProviders().get(brokerRepOrgB.getAlias()).update(brokerRepOrgB);
|
||||
OrganizationDomainRepresentation wildcardDomain = new OrganizationDomainRepresentation();
|
||||
wildcardDomain.setName("*.example.com");
|
||||
orgB.addDomain(wildcardDomain);
|
||||
|
||||
try (Response response = managedRealm.admin().organizations().get(orgB.getId()).update(orgB)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgA.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@deep.sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgA.getAlias()));
|
||||
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@some.deep.sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgA.getAlias()));
|
||||
|
||||
OrganizationRepresentation orgC = createOrganization("org-c");
|
||||
organization = managedRealm.admin().organizations().get(orgC.getId());
|
||||
broker = organization.identityProviders().get(brokerConfigFunction.apply(orgC.getAlias()).getIDPAlias());
|
||||
IdentityProviderRepresentation brokerRepOrgC = broker.toRepresentation();
|
||||
brokerRepOrgC.setHideOnLogin(false);
|
||||
brokerRepOrgC.getConfig().remove(IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
|
||||
managedRealm.admin().identityProviders().get(brokerRepOrgC.getAlias()).update(brokerRepOrgC);
|
||||
wildcardDomain = new OrganizationDomainRepresentation();
|
||||
wildcardDomain.setName("*.deep.sub.example.com");
|
||||
orgC.addDomain(wildcardDomain);
|
||||
|
||||
try (Response response = managedRealm.admin().organizations().get(orgC.getId()).update(orgC)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@deep.sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgC.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgA.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@some.deep.sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgC.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgA.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
|
||||
// Scenario: adding an exact-match domain to a new org must invalidate a previously-cached wildcard
|
||||
// resolution. The lookup for "some.deep.sub.example.com" was cached above as resolving to orgC (via
|
||||
// the "*.deep.sub.example.com" wildcard). Creating orgD with the exact domain
|
||||
// "some.deep.sub.example.com" must take precedence on the next resolution.
|
||||
OrganizationRepresentation orgD = createOrganization("org-d");
|
||||
organization = managedRealm.admin().organizations().get(orgD.getId());
|
||||
broker = organization.identityProviders().get(brokerConfigFunction.apply(orgD.getAlias()).getIDPAlias());
|
||||
IdentityProviderRepresentation brokerRepOrgD = broker.toRepresentation();
|
||||
brokerRepOrgD.setHideOnLogin(false);
|
||||
brokerRepOrgD.getConfig().remove(IdentityProviderRedirectMode.EMAIL_MATCH.getKey());
|
||||
managedRealm.admin().identityProviders().get(brokerRepOrgD.getAlias()).update(brokerRepOrgD);
|
||||
OrganizationDomainRepresentation exactDomain = new OrganizationDomainRepresentation();
|
||||
exactDomain.setName("some.deep.sub.example.com");
|
||||
orgD.addDomain(exactDomain);
|
||||
|
||||
try (Response response = managedRealm.admin().organizations().get(orgD.getId()).update(orgD)) {
|
||||
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
|
||||
}
|
||||
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@some.deep.sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgD.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgA.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgC.getAlias()));
|
||||
|
||||
// siblings of the exact-match domain must still resolve to the more-specific wildcard (orgC) and
|
||||
// not be impacted by the new exact-match org
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@another.deep.sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgC.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgD.getAlias()));
|
||||
|
||||
// Scenario: removing the more-specific wildcard org (orgC) must invalidate cached lookups that
|
||||
// resolved to it, so that subsequent resolution falls back to the broader wildcard (orgB).
|
||||
managedRealm.admin().organizations().get(orgC.getId()).delete().close();
|
||||
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@deep.sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgA.getAlias()));
|
||||
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@another.deep.sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
|
||||
// exact-match org for "some.deep.sub.example.com" still wins over the broader wildcard
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@some.deep.sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgD.getAlias()));
|
||||
assertFalse(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
|
||||
// Scenario: removing the exact-match org (orgA) must invalidate the cached
|
||||
// "sub.example.com" -> orgA entry so the lookup falls back to the wildcard (orgB).
|
||||
managedRealm.admin().organizations().get(orgA.getId()).delete().close();
|
||||
|
||||
loginPage.open(TEST_REALM_NAME);
|
||||
log.debug("Logging in");
|
||||
loginPage.loginUsername("user@sub.example.com");
|
||||
assertTrue(loginPage.isSocialButtonPresent(brokerRepOrgB.getAlias()));
|
||||
}
|
||||
|
||||
private void assertIsNotMember(String userEmail, OrganizationResource organization) {
|
||||
UsersResource users = adminClient.realm(bc.consumerRealmName()).users();
|
||||
List<UserRepresentation> reps = users.searchByEmail(userEmail, true);
|
||||
|
||||
+81
@@ -37,6 +37,7 @@ import org.keycloak.common.util.UriUtils;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.OrganizationModel;
|
||||
import org.keycloak.organization.protocol.mappers.oidc.OrganizationMembershipMapper;
|
||||
import org.keycloak.organization.utils.Organizations;
|
||||
import org.keycloak.protocol.ProtocolMapperUtils;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
|
||||
@@ -50,6 +51,7 @@ import org.keycloak.representations.idm.ClientScopeRepresentation;
|
||||
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
|
||||
import org.keycloak.representations.idm.IdentityProviderRepresentation;
|
||||
import org.keycloak.representations.idm.MemberRepresentation;
|
||||
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||
import org.keycloak.representations.idm.OrganizationRepresentation;
|
||||
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
@@ -1645,6 +1647,85 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest
|
||||
assertThat(orgClaims.get("id"), not(equalTo("custom-id-value")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDomainClaim() {
|
||||
OrganizationRepresentation orgA = createOrganization("orga", true);
|
||||
OrganizationResource organization = managedRealm.admin().organizations().get(orgA.getId());
|
||||
OrganizationDomainRepresentation domain = orgA.getDomains().iterator().next();
|
||||
MemberRepresentation member = addMember(organization, "member@" + domain.getName());
|
||||
|
||||
domain.setName("*." + domain.getName());
|
||||
organization.update(orgA).close();
|
||||
|
||||
setMapperConfig(OrganizationMembershipMapper.ADD_ORGANIZATION_DOMAIN, Boolean.TRUE.toString());
|
||||
setMapperConfig(OIDCAttributeMapperHelper.JSON_TYPE, "JSON");
|
||||
getCleanup().addCleanup(() -> {
|
||||
setMapperConfig(OrganizationMembershipMapper.ADD_ORGANIZATION_DOMAIN, Boolean.FALSE.toString());
|
||||
setMapperConfig(OIDCAttributeMapperHelper.JSON_TYPE, "String");
|
||||
});
|
||||
oauth.client("broker-app", KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
|
||||
String orgScope = "organization";
|
||||
oauth.scope(orgScope);
|
||||
loginPage.open(bc.consumerRealmName());
|
||||
loginPage.loginUsername(member.getEmail());
|
||||
loginPage.login(memberPassword);
|
||||
String code = oauth.parseLoginResponse().getCode();
|
||||
AccessTokenResponse response = oauth.doAccessTokenRequest(code);
|
||||
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
|
||||
Map<String, Map<String, String>> organizations = (Map<String, Map<String, String>>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
|
||||
assertThat(organizations.keySet(), hasItem(orgA.getAlias()));
|
||||
Map<String, String> orgClaims = organizations.get(orgA.getAlias());
|
||||
assertThat(orgClaims.get("domain"), is(domain.getName()));
|
||||
|
||||
String memberEmailDomain = Organizations.getEmailDomain(this.memberEmail);
|
||||
memberEmailDomain = "sub." + memberEmailDomain;
|
||||
member.setEmail("test@" + memberEmailDomain);
|
||||
managedRealm.admin().users().get(member.getId()).update(new UserRepresentation(member));
|
||||
orgA.addDomain(new OrganizationDomainRepresentation(memberEmailDomain));
|
||||
organization.update(orgA).close();
|
||||
|
||||
managedRealm.admin().users().get(member.getId()).logout();
|
||||
loginPage.open(bc.consumerRealmName());
|
||||
loginPage.loginUsername(member.getEmail());
|
||||
loginPage.login(memberPassword);
|
||||
code = oauth.parseLoginResponse().getCode();
|
||||
response = oauth.doAccessTokenRequest(code);
|
||||
accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
|
||||
organizations = (Map<String, Map<String, String>>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
|
||||
assertThat(organizations.keySet(), hasItem(orgA.getAlias()));
|
||||
orgClaims = organizations.get(orgA.getAlias());
|
||||
assertThat(orgClaims.get("domain"), is(memberEmailDomain));
|
||||
|
||||
memberEmailDomain = Organizations.getEmailDomain(member.getEmail());
|
||||
member.setEmail("test@deep." + memberEmailDomain);
|
||||
managedRealm.admin().users().get(member.getId()).update(new UserRepresentation(member));
|
||||
orgA.addDomain(new OrganizationDomainRepresentation("*." + memberEmailDomain));
|
||||
organization.update(orgA).close();
|
||||
|
||||
managedRealm.admin().users().get(member.getId()).logout();
|
||||
loginPage.open(bc.consumerRealmName());
|
||||
loginPage.loginUsername(member.getEmail());
|
||||
loginPage.login(memberPassword);
|
||||
code = oauth.parseLoginResponse().getCode();
|
||||
response = oauth.doAccessTokenRequest(code);
|
||||
accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
|
||||
organizations = (Map<String, Map<String, String>>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
|
||||
assertThat(organizations.keySet(), hasItem(orgA.getAlias()));
|
||||
orgClaims = organizations.get(orgA.getAlias());
|
||||
assertThat(orgClaims.get("domain"), is("*." + memberEmailDomain));
|
||||
|
||||
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
|
||||
accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
|
||||
organizations = (Map<String, Map<String, String>>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
|
||||
assertThat(organizations.keySet(), hasItem(orgA.getAlias()));
|
||||
orgClaims = organizations.get(orgA.getAlias());
|
||||
assertThat(orgClaims.get("domain"), is("*." + memberEmailDomain));
|
||||
}
|
||||
|
||||
private AccessTokenResponse assertSuccessfulCodeGrant() {
|
||||
return assertSuccessfulCodeGrant(oauth);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user