mirror of
https://github.com/keycloak/keycloak.git
synced 2026-06-11 01:50:04 -04:00
[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:
parent
67368e3ac0
commit
f19e1f2b4e
21 changed files with 305 additions and 88 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<>() {});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in a new issue