8000 Add Wildcard Subdomain Matching Support for Organization Domains by shaidar · Pull Request #45190 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@

/**
* 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>
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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::
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a new option to allow setting a comma-separated list of domains to exclude from auto-redirect.

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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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*.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -32,6 +36,7 @@ type LinkRepresentation = {
hideOnLogin: boolean;
config: {
"kc.org.domain": string;
"kc.org.excluded.domains": string;
};
};

Expand Down Expand Up @@ -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")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Al 10BC8 l @@ -34,6 +38,8 @@

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;
Expand All @@ -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();
Expand All @@ -54,6 +61,20 @@ public CachedOrganization(long revision, RealmModel realm, OrganizationModel org
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());
}

Expand Down Expand Up @@ -90,6 +111,14 @@ public Stream<OrganizationDomainModel> getDomains() {
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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
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;
Expand Down Expand Up @@ -69,7 +68,7 @@ public static String cacheKeyOrgMemberCount(RealmModel realm, OrganizationModel
@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() {
Expand Down Expand Up @@ -104,10 +103,8 @@ public OrganizationModel getById(String id) {
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)) {
Expand Down Expand Up @@ -139,6 +136,10 @@ public OrganizationModel getByDomainName(String domainName) {
}
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);
Expand Down Expand Up @@ -437,10 +438,13 @@ void registerOrganizationInvalidation(OrganizationModel organization) {
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);
Expand Down Expand Up @@ -468,11 +472,16 @@ private Stream<OrganizationModel> getCacheDelegates(Stream<OrganizationModel> ba
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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 {

Expand Down Expand Up @@ -159,6 +160,7 @@ public Stream<OrganizationDomainModel> getDomains() {
@Override
public void setDomains(Set<OrganizationDomainModel> domains) {
getDelegateForUpdate();
invalidateDomains(domains);
updated.setDomains(domains);
}

Expand Down Expand Up @@ -193,4 +195,23 @@ public boolean equals(Object o) {
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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
@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"),
@NamedQuery(name="getInternalOrgGroupsByMember", query="select m.groupId from UserGroupMembershipEntity m join GroupEntity g on g.id = m.groupId where g.type = 1 and m.user.id = :userId"),
Expand Down
Loading
Loading
0