diff --git a/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java b/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java index 7d7ec81dd8b..1516ac288a9 100644 --- a/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java +++ b/server-spi-private/src/main/java/org/keycloak/authorization/fgap/AdminPermissionsSchema.java @@ -257,7 +257,14 @@ public class AdminPermissionsSchema extends AuthorizationSchema { } private Optional 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 resolveClient(KeycloakSession session, String id) { diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java index f4bc6f475aa..bcb1fcf0d93 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java @@ -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 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 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(); diff --git a/services/src/main/java/org/keycloak/authentication/forms/RegistrationPage.java b/services/src/main/java/org/keycloak/authentication/forms/RegistrationPage.java index 44f0328d2cd..6654377d9ea 100755 --- a/services/src/main/java/org/keycloak/authentication/forms/RegistrationPage.java +++ b/services/src/main/java/org/keycloak/authentication/forms/RegistrationPage.java @@ -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()); diff --git a/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java b/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java index 4a8c94cf133..8f2aa05b50a 100755 --- a/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java +++ b/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java @@ -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 formData, String email) { - if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { + if (Organizations.isEnabled(context.getSession())) { Consumer> 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) { diff --git a/services/src/main/java/org/keycloak/email/freemarker/beans/ProfileBean.java b/services/src/main/java/org/keycloak/email/freemarker/beans/ProfileBean.java index b47fcb468d2..19e4cd4d6c8 100755 --- a/services/src/main/java/org/keycloak/email/freemarker/beans/ProfileBean.java +++ b/services/src/main/java/org/keycloak/email/freemarker/beans/ProfileBean.java @@ -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 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(); } diff --git a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java index 11b8fb0c706..df67c618e42 100644 --- a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationGroupMembershipMapper.java @@ -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 organizations; diff --git a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java index de4269050e1..fc8038cf459 100644 --- a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java @@ -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 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); } diff --git a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationTokenPostProcessor.java b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationTokenPostProcessor.java index c3acc12a440..418b53386d9 100644 --- a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationTokenPostProcessor.java +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationTokenPostProcessor.java @@ -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; } diff --git a/services/src/main/java/org/keycloak/organization/utils/Organizations.java b/services/src/main/java/org/keycloak/organization/utils/Organizations.java index ff1c3eaf2b3..2032e99b1e0 100644 --- a/services/src/main/java/org/keycloak/organization/utils/Organizations.java +++ b/services/src/main/java/org/keycloak/organization/utils/Organizations.java @@ -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 resolveHomeBroker(KeycloakSession session, UserModel user) { + if (!isEnabled(session)) { + return List.of(); + } OrganizationProvider provider = getProvider(session); RealmModel realm = session.getContext().getRealm(); List 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; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 8c1ca9bbc13..5929075d61b 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -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 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 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; } diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index 7d1ab37ef64..219e5950e30 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -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) { diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java index 34d5d027655..724de629a75 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java @@ -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); diff --git a/ssf/core/src/main/java/org/keycloak/ssf/subject/SubjectResolver.java b/ssf/core/src/main/java/org/keycloak/ssf/subject/SubjectResolver.java index 607fba258dd..bc7a43430f9 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/subject/SubjectResolver.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/subject/SubjectResolver.java @@ -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) { diff --git a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EventEmitterService.java b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EventEmitterService.java index 1fdb4d64c23..a4e63098817 100644 --- a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EventEmitterService.java +++ b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EventEmitterService.java @@ -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()); diff --git a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SecurityEventTokenMapper.java b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SecurityEventTokenMapper.java index 09fea9213d5..8bfe9bcd819 100644 --- a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SecurityEventTokenMapper.java +++ b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SecurityEventTokenMapper.java @@ -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 " diff --git a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SsfTransmitterEventListener.java b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SsfTransmitterEventListener.java index c9ebeebea3b..d97cc237089 100644 --- a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SsfTransmitterEventListener.java +++ b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SsfTransmitterEventListener.java @@ -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() diff --git a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SsfNotifyAttributes.java b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SsfNotifyAttributes.java index 7b4b8c597b2..e62fc2bb882 100644 --- a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SsfNotifyAttributes.java +++ b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SsfNotifyAttributes.java @@ -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.} @@ -240,13 +240,10 @@ public final class SsfNotifyAttributes { public static Stream 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); } } diff --git a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SubjectManagementService.java b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SubjectManagementService.java index 9d047f96c50..537da9c53b2 100644 --- a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SubjectManagementService.java +++ b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SubjectManagementService.java @@ -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; } diff --git a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SubjectSubscriptionFilter.java b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SubjectSubscriptionFilter.java index c7f0348ca80..0cf2dfc5bd3 100644 --- a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SubjectSubscriptionFilter.java +++ b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/subject/SubjectSubscriptionFilter.java @@ -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)); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/account/OrganizationAccountTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/account/OrganizationAccountTest.java index 72012d956ed..88228ef17da 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/account/OrganizationAccountTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/account/OrganizationAccountTest.java @@ -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 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 linkedAccountsRep() throws IOException { return SimpleHttpDefault.doGet(getAccountUrl("linked-accounts"), client).auth(tokenUtil.getToken()) .asJson(new TypeReference<>() {}); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java index 11b5cb4128f..ab86e73dc75 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java @@ -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());