[CVE-2026-9791] Organization data exposed in tokens and account API when Organizations feature is disabled at realm level (#49541)

Closes #49431

Signed-off-by: Martin Kanis <mkanis@ibm.com>
Co-authored-by: Martin Kanis <mkanis@ibm.com>
This commit is contained in:
Martin Kanis 2026-06-04 08:27:16 +02:00 committed by GitHub
parent 67368e3ac0
commit f19e1f2b4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 305 additions and 88 deletions

View file

@ -257,7 +257,14 @@ public class AdminPermissionsSchema extends AuthorizationSchema {
}
private Optional<OrganizationModel> resolveOrganization(KeycloakSession session, String id) {
return Optional.ofNullable(session.getProvider(OrganizationProvider.class).getById(id));
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
return Optional.empty();
}
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
if (provider == null || !provider.isEnabled()) {
return Optional.empty();
}
return Optional.ofNullable(provider.getById(id));
}
private Optional<ClientModel> resolveClient(KeycloakSession session, String id) {

View file

@ -42,6 +42,7 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.InvitationManager;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager;
@ -77,6 +78,11 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler<Invi
@Override
public Response preHandleToken(InviteOrgActionToken token, ActionTokenContext<InviteOrgActionToken> tokenContext) {
KeycloakSession session = tokenContext.getSession();
if (!Organizations.isEnabled(session)) {
return disabledOrganizationResponse(tokenContext, token);
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
OrganizationModel organization = orgProvider.getById(token.getOrgId());
@ -113,6 +119,11 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler<Invi
public Response handleToken(InviteOrgActionToken token, ActionTokenContext<InviteOrgActionToken> tokenContext) {
UserModel user = tokenContext.getAuthenticationSession().getAuthenticatedUser();
KeycloakSession session = tokenContext.getSession();
if (!Organizations.isEnabled(session)) {
return disabledOrganizationResponse(tokenContext, token);
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession();
EventBuilder event = tokenContext.getEvent();

View file

@ -27,8 +27,6 @@ import org.keycloak.authentication.FormAuthenticator;
import org.keycloak.authentication.FormAuthenticatorFactory;
import org.keycloak.authentication.FormContext;
import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.VerificationException;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.AuthenticationExecutionModel;
@ -57,7 +55,7 @@ public class RegistrationPage implements FormAuthenticator, FormAuthenticatorFac
@Override
public Response render(FormContext context, LoginFormsProvider form) {
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
if (Organizations.isEnabled(context.getSession())) {
try {
InviteOrgActionToken token = Organizations.parseInvitationToken(context.getSession(), context.getHttpRequest());

View file

@ -32,8 +32,6 @@ import org.keycloak.authentication.FormContext;
import org.keycloak.authentication.ValidationContext;
import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken;
import org.keycloak.authentication.requiredactions.TermsAndConditions;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
@ -294,7 +292,7 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
}
private boolean validateOrganizationInvitation(ValidationContext context, MultivaluedMap<String, String> formData, String email) {
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
if (Organizations.isEnabled(context.getSession())) {
Consumer<List<FormMessage>> error = messages -> {
context.error(Errors.INVALID_TOKEN);
context.validationError(formData, messages);
@ -355,7 +353,7 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
}
private void addOrganizationMember(FormContext context, UserModel user) {
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
if (Organizations.isEnabled(context.getSession())) {
InviteOrgActionToken token = (InviteOrgActionToken) context.getSession().getAttribute(InviteOrgActionToken.class.getName());
if (token != null) {

View file

@ -24,7 +24,7 @@ import java.util.Map;
import org.keycloak.forms.login.freemarker.model.OrganizationBean;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.userprofile.UserProfileProvider;
@ -90,12 +90,10 @@ public class ProfileBean {
public List<OrganizationBean> getOrganizations() {
if (organizations == null) {
final var organizationsProvider = session.getProvider(OrganizationProvider.class);
if (organizationsProvider == null) {
if (!Organizations.isEnabled(session)) {
organizations = Collections.emptyList();
}
else {
organizations = organizationsProvider.getByMember(user)
} else {
organizations = Organizations.getProvider(session).getByMember(user)
.map(o -> new OrganizationBean(o, user))
.toList();
}

View file

@ -40,6 +40,7 @@ import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.models.utils.RoleUtils;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
@ -94,6 +95,9 @@ public class OrganizationGroupMembershipMapper extends AbstractOIDCProtocolMappe
@Override
protected void setClaim(IDToken token, ProtocolMapperModel model, UserSessionModel userSession, KeycloakSession session, ClientSessionContext clientSessionCtx) {
if (!Organizations.isEnabled(session)) {
return;
}
// Get organization ID from client session or resolve from scopes
String orgId = clientSessionCtx.getClientSession().getNote(OrganizationModel.ORGANIZATION_ATTRIBUTE);
Stream<OrganizationModel> organizations;

View file

@ -39,6 +39,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.protocol.oidc.TokenManager;
@ -228,6 +229,9 @@ public enum OrganizationScope {
* @return the organizations mapped from the {@code scope} parameter. Or an empty stream if no organizations were mapped from the parameter.
*/
public Stream<OrganizationModel> resolveOrganizations(UserModel user, String scope, KeycloakSession session) {
if (!Organizations.isEnabled(session)) {
return Stream.empty();
}
return valueResolver.apply(user, Optional.ofNullable(scope).orElse(EMPTY_SCOPE), session).filter(OrganizationModel::isEnabled);
}

View file

@ -47,7 +47,7 @@ public class OrganizationTokenPostProcessor implements TokenPostProcessor{
}
private void addOrganizationRefreshTokenClaim(TokenPostProcessorContext context, String orgAlias) {
if (orgAlias == null) {
if (orgAlias == null || !Organizations.isEnabled(session)) {
return;
}

View file

@ -71,8 +71,8 @@ public class Organizations {
}
public static boolean canManageOrganizationGroup(KeycloakSession session, GroupModel group) {
// if it's not an organization group OR the feature is disabled, we don't need further checks
if (!isOrganizationGroup(group) || !Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
// if it's not an organization group OR organizations are disabled, we don't need further checks
if (!isOrganizationGroup(group) || !isEnabled(session)) {
return true;
}
@ -86,6 +86,9 @@ public class Organizations {
}
public static List<IdentityProviderModel> resolveHomeBroker(KeycloakSession session, UserModel user) {
if (!isEnabled(session)) {
return List.of();
}
OrganizationProvider provider = getProvider(session);
RealmModel realm = session.getContext().getRealm();
List<OrganizationModel> organizations = Optional.ofNullable(user).stream().flatMap(provider::getByMember)
@ -145,6 +148,14 @@ public class Organizations {
};
}
public static boolean isEnabled(KeycloakSession session) {
if (!Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
return false;
}
OrganizationProvider provider = getProvider(session);
return provider != null && provider.isEnabled();
}
public static boolean isEnabledAndOrganizationsPresent(OrganizationProvider orgProvider) {
return orgProvider != null && orgProvider.isEnabled() && orgProvider.count() != 0;
}

View file

@ -729,6 +729,15 @@ public class TokenManager {
ClientScopeModel scope = allOptionalScopes.get(name);
if (scope != null) {
// The "organization" scope is a default optional client scope, so it can be
// resolved here bypassing the dynamic scope resolution in tryResolveOrganizationClientScope.
// Skip it when organizations are disabled at the realm level.
// The getProtocolMapperByType check identifies the organization scope by its mapper,
// ensuring we only filter that scope and not unrelated ones like email or profile.
if (!Organizations.isEnabled(session)
&& !scope.getProtocolMapperByType(OrganizationMembershipMapper.PROVIDER_ID).isEmpty()) {
return null;
}
return scope;
}
@ -783,7 +792,7 @@ public class TokenManager {
Collection<String> rawScopes = TokenManager.parseScopeParameter(scopes).collect(Collectors.toSet());
// validate organization scopes - allow multiple specific organization scopes, but reject mixed types
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
if (Organizations.isEnabled(session)) {
Set<OrganizationScope> orgScopeTypes = new HashSet<>();
for (String scope : rawScopes) {
OrganizationScope orgScopeType = OrganizationScope.valueOfScope(session, scope);
@ -847,6 +856,12 @@ public class TokenManager {
for (String requestedScope : rawScopes) {
// we also check parameterized scopes in case the client is from a provider that dynamically provides scopes to their clients
if (!clientScopes.contains(requestedScope) && client.getDynamicClientScope(requestedScope) == null) {
// when organizations are disabled at realm level, silently ignore organization scopes
// so that existing clients requesting them are not broken
if (!Organizations.isEnabled(session)
&& OrganizationScope.valueOfScope(session, requestedScope) != null) {
continue;
}
return false;
}
}
@ -1722,7 +1737,7 @@ public class TokenManager {
}
private void validateSelectedOrganization(KeycloakSession session, JsonWebToken token, UserModel user) {
if (token == null) {
if (token == null || !Organizations.isEnabled(session)) {
return;
}

View file

@ -54,7 +54,6 @@ import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAu
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Time;
import org.keycloak.common.util.TriFunction;
@ -989,7 +988,7 @@ public class LoginActionsService {
}
private void configureOrganization(BrokeredIdentityContext brokerContext) {
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
if (Organizations.isEnabled(session)) {
String organizationId = brokerContext.getIdpConfig().getOrganizationId();
if (organizationId != null) {

View file

@ -47,7 +47,6 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.enums.AccountRestApiVersion;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
@ -62,6 +61,7 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation;
@ -240,7 +240,7 @@ public class AccountRestService {
@Path("/organizations")
public OrganizationsResource organizations() {
checkAccountApiEnabled();
if (!Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
if (!Organizations.isEnabled(session)) {
throw new NotFoundException();
}
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);

View file

@ -5,6 +5,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.jboss.logging.Logger;
@ -71,15 +72,12 @@ public class SubjectResolver {
}
private static SubjectResolution resolveOrganization(KeycloakSession session, SubjectId tenantSubject) {
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
if (!Organizations.isEnabled(session)) {
log.debugf("Organization feature is disabled — cannot resolve tenant subject");
return SubjectResolution.UNSUPPORTED_FORMAT;
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider == null) {
return SubjectResolution.UNSUPPORTED_FORMAT;
}
// opaque id getById
if (tenantSubject instanceof OpaqueSubjectId opaque) {

View file

@ -3,13 +3,13 @@ package org.keycloak.ssf.transmitter.emit;
import java.util.Map;
import java.util.function.Supplier;
import org.keycloak.common.Profile;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.ssf.SsfException;
import org.keycloak.ssf.event.SsfEvent;
import org.keycloak.ssf.event.SsfEventRegistry;
@ -248,16 +248,13 @@ public class EventEmitterService {
}
protected OrganizationModel resolveOrganization(SubjectId tenantFacet) {
if (tenantFacet == null || !Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
if (tenantFacet == null || !Organizations.isEnabled(session)) {
return null;
}
if (!(tenantFacet instanceof OpaqueSubjectId opaque) || opaque.getId() == null) {
return null;
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider == null) {
return null;
}
// Prefer alias (matches the admin shorthand 'org-alias' convention),
// then fall back to UUID for emitters that prefer stable identifiers.
OrganizationModel org = orgProvider.getByAlias(opaque.getId());

View file

@ -25,6 +25,7 @@ import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.ssf.SsfException;
import org.keycloak.ssf.event.InitiatingEntity;
import org.keycloak.ssf.event.caep.CaepCredentialChange;
@ -524,11 +525,11 @@ public class SecurityEventTokenMapper {
+ (stream != null ? stream.getStreamId() : null) + ")");
}
RealmModel realm = session.getContext().getRealm();
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider == null) {
if (!Organizations.isEnabled(session)) {
throw new SsfException("Cannot build tenant subject: organization feature is not enabled (stream "
+ (stream != null ? stream.getStreamId() : null) + ")");
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
UserModel user = session.users().getUserById(realm, userId);
if (user == null) {
throw new SsfException("Cannot build tenant subject: user " + userId + " not found (stream "

View file

@ -4,7 +4,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
@ -16,6 +15,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.ssf.Ssf;
import org.keycloak.ssf.event.token.SsfSecurityEventToken;
import org.keycloak.ssf.metadata.DefaultSubjects;
@ -270,13 +270,10 @@ public class SsfTransmitterEventListener implements EventListenerProvider {
* truth for both the auto-notify guard and the dispatch gate.
*/
protected boolean isAnyOrganizationNotified(SsfTransmitterProvider transmitter, UserModel user, ClientModel client) {
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
if (!Organizations.isEnabled(session)) {
return false;
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider == null) {
return false;
}
String receiverClientId = client.getClientId();
return orgProvider.getByMember(user)
.anyMatch(org -> transmitter.subjectInclusionResolver()

View file

@ -5,13 +5,13 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
/**
* Helpers for reading and writing the {@code ssf.notify.<clientId>}
@ -240,13 +240,10 @@ public final class SsfNotifyAttributes {
public static Stream<OrganizationModel> findAllNotifiedOrganizations(KeycloakSession session,
String clientId) {
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
if (!Organizations.isEnabled(session)) {
return Stream.empty();
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider == null) {
return Stream.empty();
}
return orgProvider.getAllStream(Map.of(attributeKey(clientId), ATTRIBUTE_VALUE_TRUE), null, null);
return session.getProvider(OrganizationProvider.class)
.getAllStream(Map.of(attributeKey(clientId), ATTRIBUTE_VALUE_TRUE), null, null);
}
}

View file

@ -1,12 +1,12 @@
package org.keycloak.ssf.transmitter.subject;
import org.keycloak.common.Profile;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.ssf.SsfException;
import org.keycloak.ssf.metadata.DefaultSubjects;
import org.keycloak.ssf.subject.ComplexSubjectId;
@ -335,28 +335,20 @@ public class SubjectManagementService {
* which org tipped the decision.
*/
protected OrganizationModel firstOrgNotifying(UserModel user, String receiverClientId) {
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
if (!Organizations.isEnabled(session)) {
return null;
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider == null) {
return null;
}
return orgProvider.getByMember(user)
return session.getProvider(OrganizationProvider.class).getByMember(user)
.filter(org -> SsfNotifyAttributes.isOrganizationNotified(org, receiverClientId))
.findFirst()
.orElse(null);
}
protected OrganizationModel firstOrgExcluding(UserModel user, String receiverClientId) {
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
if (!Organizations.isEnabled(session)) {
return null;
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider == null) {
return null;
}
return orgProvider.getByMember(user)
return session.getProvider(OrganizationProvider.class).getByMember(user)
.filter(org -> SsfNotifyAttributes.isOrganizationExcluded(org, receiverClientId))
.findFirst()
.orElse(null);
@ -403,14 +395,10 @@ public class SubjectManagementService {
return user != null ? new SubjectResolution.User(user) : SubjectResolution.NOT_FOUND;
}
if ("org-alias".equals(type)) {
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
if (!Organizations.isEnabled(session)) {
return SubjectResolution.UNSUPPORTED_FORMAT;
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider == null) {
return SubjectResolution.UNSUPPORTED_FORMAT;
}
var org = orgProvider.getByAlias(value);
var org = session.getProvider(OrganizationProvider.class).getByAlias(value);
return org != null ? new SubjectResolution.Organization(org) : SubjectResolution.NOT_FOUND;
}

View file

@ -1,12 +1,12 @@
package org.keycloak.ssf.transmitter.subject;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.ssf.event.stream.SsfStreamUpdatedEvent;
import org.keycloak.ssf.event.stream.SsfStreamVerificationEvent;
import org.keycloak.ssf.event.token.SsfSecurityEventToken;
@ -218,16 +218,13 @@ public class SubjectSubscriptionFilter {
if (userTombstone != null && now - userTombstone < graceSeconds) {
return true;
}
if (Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider != null) {
return orgProvider.getByMember(user)
.anyMatch(org -> {
Long orgTombstone = SsfNotifyAttributes.getRemovedAtForOrganization(org, receiverClientId);
return orgTombstone != null
&& now - orgTombstone < graceSeconds;
});
}
if (Organizations.isEnabled(session)) {
return session.getProvider(OrganizationProvider.class).getByMember(user)
.anyMatch(org -> {
Long orgTombstone = SsfNotifyAttributes.getRemovedAtForOrganization(org, receiverClientId);
return orgTombstone != null
&& now - orgTombstone < graceSeconds;
});
}
return false;
}
@ -285,14 +282,10 @@ public class SubjectSubscriptionFilter {
* "is the user excluded *via* one of their orgs" question.
*/
protected boolean isOrganizationExcluded(UserModel user, String receiverClientId, KeycloakSession session) {
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
if (!Organizations.isEnabled(session)) {
return false;
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider == null) {
return false;
}
return orgProvider.getByMember(user)
return session.getProvider(OrganizationProvider.class).getByMember(user)
.anyMatch(org -> subjectInclusionResolver.isOrganizationExcluded(session, org, receiverClientId));
}
@ -301,14 +294,10 @@ public class SubjectSubscriptionFilter {
* per the {@link #subjectInclusionResolver}.
*/
protected boolean isOrganizationNotified(UserModel user, String receiverClientId, KeycloakSession session) {
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
if (!Organizations.isEnabled(session)) {
return false;
}
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
if (orgProvider == null) {
return false;
}
return orgProvider.getByMember(user)
return session.getProvider(OrganizationProvider.class).getByMember(user)
.anyMatch(org -> subjectInclusionResolver.isOrganizationNotified(session, org, receiverClientId));
}
}

View file

@ -34,6 +34,7 @@ import org.keycloak.testframework.realm.UserBuilder;
import org.keycloak.testsuite.admin.AdminApiUtil;
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.TokenUtil;
import com.fasterxml.jackson.core.type.TypeReference;
@ -113,6 +114,30 @@ public class OrganizationAccountTest extends AbstractOrganizationTest {
Assertions.assertTrue(organization.getDomains().containsAll(orgA.getDomains().stream().map(OrganizationDomainRepresentation::getName).toList()));
}
@Test
public void testGetOrganizationsWhenOrganizationsDisabled() throws Exception {
UserRepresentation member = createUser();
org.keycloak.representations.idm.OrganizationRepresentation orgA = createOrganization("orga");
managedRealm.admin().organizations().get(orgA.getId()).members().addMember(member.getId()).close();
// verify account API returns organization data when organizations are enabled
List<OrganizationRepresentation> organizations = getOrganizations();
Assertions.assertEquals(1, organizations.size());
Assertions.assertEquals(orgA.getId(), organizations.get(0).getId());
// disable organizations on the realm
try (RealmAttributeUpdater rau = new RealmAttributeUpdater(managedRealm.admin())
.setOrganizationsEnabled(Boolean.FALSE)
.update()) {
// account API should not return organization data when organizations are disabled
try (SimpleHttpResponse response = SimpleHttpDefault.doGet(getAccountUrl("organizations"), client)
.auth(tokenUtil.getToken()).acceptJson().asResponse()) {
Assertions.assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
}
}
}
private SortedSet<LinkedAccountRepresentation> linkedAccountsRep() throws IOException {
return SimpleHttpDefault.doGet(getAccountUrl("linked-accounts"), client).auth(tokenUtil.getToken())
.asJson(new TypeReference<>() {});

View file

@ -56,9 +56,11 @@ import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.oidc.TokenMetadataRepresentation;
import org.keycloak.testframework.realm.ClientBuilder;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.broker.KcOidcBrokerConfiguration;
import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.BrowserTabUtil;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.IntrospectionResponse;
@ -1802,6 +1804,184 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest
assertThat(accessToken.getOtherClaims().keySet(), not(hasItem(OAuth2Constants.ORGANIZATION)));
}
@Test
public void testOrganizationClaimNotIssuedWhenOrganizationsDisabledCodeGrant() throws Exception {
OrganizationResource orgA = managedRealm.admin().organizations().get(createOrganization("orga").getId());
addMember(orgA);
// verify organization claim IS present when organizations are enabled (via password grant)
oauth.client("direct-grant", "password");
oauth.scope("openid organization:*");
AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword);
assertThat(response.getStatusCode(), is(200));
AccessToken accessToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken();
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
// now disable organizations on the realm and test the code grant flow
// when organizations are disabled, the org authenticator skips (calls attempted()),
// so the standard username/password login form is shown
try (RealmAttributeUpdater rau = new RealmAttributeUpdater(managedRealm.admin())
.setOrganizationsEnabled(Boolean.FALSE)
.update()) {
oauth.client("broker-app", KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
oauth.scope("organization:*");
loginPage.open(bc.consumerRealmName());
loginPage.login(memberEmail, memberPassword);
String code = oauth.parseLoginResponse().getCode();
response = oauth.doAccessTokenRequest(code);
assertThat(response.getStatusCode(), is(Status.OK.getStatusCode()));
assertResponseMissingOrganizationScopeAndClaims(response);
}
}
@Test
public void testOrganizationClaimNotIssuedWhenOrganizationsDisabledClientCredentials() throws Exception {
OrganizationRepresentation orgA = createOrganization("orga");
// create a client with service accounts enabled
ClientRepresentation serviceClient = ClientBuilder.create()
.clientId("service-account-org-test")
.secret("secret")
.serviceAccountsEnabled(true)
.build();
managedRealm.admin().clients().create(serviceClient).close();
ClientRepresentation createdClient = managedRealm.admin().clients().findByClientId("service-account-org-test").get(0);
getCleanup().addCleanup(() -> managedRealm.admin().clients().get(createdClient.getId()).remove());
// add the organization scope as optional client scope to the service account client
ClientScopeRepresentation orgScope = managedRealm.admin().clientScopes().findAll().stream()
.filter(s -> OIDCLoginProtocolFactory.ORGANIZATION.equals(s.getName()))
.findAny()
.orElseThrow();
managedRealm.admin().clients().get(createdClient.getId()).addOptionalClientScope(orgScope.getId());
// make the service account user a member of the organization
String serviceAccountUserId = managedRealm.admin().clients().get(createdClient.getId()).getServiceAccountUser().getId();
managedRealm.admin().organizations().get(orgA.getId()).members().addMember(serviceAccountUserId).close();
// first, verify organization claim IS present when organizations are enabled
oauth.client("service-account-org-test", "secret");
oauth.scope("openid organization:*");
AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest();
assertThat(response.getStatusCode(), is(200));
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
// now disable organizations on the realm
try (RealmAttributeUpdater rau = new RealmAttributeUpdater(managedRealm.admin())
.setOrganizationsEnabled(Boolean.FALSE)
.update()) {
// attempt client credentials grant with organization scope should not include organization claim
oauth.client("service-account-org-test", "secret");
oauth.scope("openid organization:*");
response = oauth.doClientCredentialsGrantAccessTokenRequest();
assertThat(response.getStatusCode(), is(200));
assertResponseMissingOrganizationScopeAndClaims(response);
}
}
@Test
public void testOrganizationClaimNotIssuedWhenOrganizationsDisabledPasswordGrant() throws Exception {
OrganizationResource orgA = managedRealm.admin().organizations().get(createOrganization("orga").getId());
addMember(orgA);
// verify organization claim IS present when organizations are enabled
oauth.client("direct-grant", "password");
oauth.scope("openid organization:*");
AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword);
assertThat(response.getStatusCode(), is(200));
AccessToken accessToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken();
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
// now disable organizations on the realm
try (RealmAttributeUpdater rau = new RealmAttributeUpdater(managedRealm.admin())
.setOrganizationsEnabled(Boolean.FALSE)
.update()) {
// password grant with organization scope should not include organization claim
oauth.client("direct-grant", "password");
oauth.scope("openid organization:*");
response = oauth.doPasswordGrantRequest(memberEmail, memberPassword);
assertThat(response.getStatusCode(), is(200));
assertResponseMissingOrganizationScopeAndClaims(response);
}
}
@Test
public void testOrganizationClaimNotIssuedOnRefreshWhenOrganizationsDisabled() throws Exception {
OrganizationResource orgA = managedRealm.admin().organizations().get(createOrganization("orga").getId());
addMember(orgA);
// get a token with organization claims while orgs are enabled
oauth.client("direct-grant", "password");
oauth.scope("openid organization:*");
AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword);
assertThat(response.getStatusCode(), is(200));
assertThat(response.getRefreshToken(), notNullValue());
AccessToken accessToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).getToken();
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
String refreshToken = response.getRefreshToken();
// disable organizations on the realm, then refresh the token
try (RealmAttributeUpdater rau = new RealmAttributeUpdater(managedRealm.admin())
.setOrganizationsEnabled(Boolean.FALSE)
.update()) {
// refresh should succeed but the new token should not contain organization claims
response = oauth.doRefreshTokenRequest(refreshToken);
assertThat(response.getStatusCode(), is(200));
assertResponseMissingOrganizationScopeAndClaims(response);
}
}
@Test
public void testOrganizationScopeFilteredButOtherScopesPreservedWhenOrganizationsDisabled() throws Exception {
OrganizationResource orgA = managedRealm.admin().organizations().get(createOrganization("orga").getId());
addMember(orgA);
// create a dedicated client with direct access grants and the "organization" optional scope
ClientRepresentation testClient = ClientBuilder.create()
.clientId("org-scope-test")
.secret("secret")
.directAccessGrantsEnabled()
.build();
managedRealm.admin().clients().create(testClient).close();
ClientRepresentation createdClient = managedRealm.admin().clients().findByClientId("org-scope-test").get(0);
getCleanup().addCleanup(() -> managedRealm.admin().clients().get(createdClient.getId()).remove());
ClientScopeRepresentation orgScope = managedRealm.admin().clientScopes().findAll().stream()
.filter(s -> OIDCLoginProtocolFactory.ORGANIZATION.equals(s.getName()))
.findAny()
.orElseThrow();
managedRealm.admin().clients().get(createdClient.getId()).addOptionalClientScope(orgScope.getId());
// disable organizations on the realm
try (RealmAttributeUpdater rau = new RealmAttributeUpdater(managedRealm.admin())
.setOrganizationsEnabled(Boolean.FALSE)
.update()) {
// request the plain "organization" scope (ANY variant, resolved via allOptionalScopes)
// alongside other standard scopes
oauth.client("org-scope-test", "secret");
oauth.scope("openid email profile organization");
AccessTokenResponse response = oauth.doPasswordGrantRequest(memberEmail, memberPassword);
assertThat(response.getStatusCode(), is(200));
// organization scope should be stripped
assertThat(response.getScope(), not(containsString("organization")));
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
assertThat(accessToken.getOtherClaims().keySet(), not(hasItem(OAuth2Constants.ORGANIZATION)));
// but email and profile scopes should still be granted
assertThat(response.getScope(), containsString("email"));
assertThat(response.getScope(), containsString("profile"));
}
}
private void assertClaimNotMapped(String orgScope, OrganizationRepresentation orgARep, boolean grantScope) {
OrganizationResource orgA = managedRealm.admin().organizations().get(orgARep.getId());
MemberRepresentation member = addMember(orgA, "member@" + orgARep.getDomains().iterator().next().getName());