8000 Restrict defaultRoles and full scope via client registration policies by edewit · Pull Request #54887 · keycloak/keycloak · GitHub
[go: up one dir, main page]

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
8000
Diff view
19 changes: 19 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-27_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@
Breaking changes are identified as those that might require changes for existing users to their configurations or applications.
In minor or patch releases, {project_name} will only introduce breaking changes to fix bugs.

=== Client registration policies restrict default roles and full scope (CVE-2026-109081)
// https://github.com/keycloak/keycloak/issues/54702

Dynamic Client Registration can no longer change the realm-wide default-role composite through the deprecated `defaultRoles` field, and authenticated registration can no longer enable `fullScopeAllowed`, unless a realm administrator removes the corresponding client registration policies.

Starting with this release:

* A *Default Roles Disabled* client registration policy is added to both the anonymous and authenticated client registration policies (including during migration of existing realms).
While this policy is present, create or update requests that would add, change, or remove roles in the realm `default-roles-<realm>` composite through `defaultRoles` are rejected with HTTP `403 Forbidden`.
* The existing *Full Scope Disabled* policy (`scope`) is also added to the authenticated client registration policies (it was already present for anonymous registration).
Newly registered clients through authenticated Dynamic Client Registration have `fullScopeAllowed` disabled, and updates that try to enable it are rejected with HTTP `403 Forbidden`.

Realm administrators who intentionally want Dynamic Client Registration to manage these settings can remove the corresponding policies under *Clients* -> *Client registration* -> *Client registration policies*.
Removing them restores the previous behavior and should be done only when the security implications are understood.
Use the Admin REST API to manage realm default roles and full scope when the policies remain in place.

This migration installs a new client registration policy provider that earlier nodes do not know.
A rolling upgrade is therefore not supported for this change; shut down the cluster and upgrade all nodes together (or use a recreate update) so that older nodes never process Dynamic Client Registration requests against the new policy components.

=== PAR metadata omitted when PAR is disabled

When the `par` feature is disabled, OpenID Connect discovery now omits `pushed_authorization_request_endpoint` from both the top-level metadata and `mtls_endpoint_aliases`, and omits `require_pushed_authorization_requests`.
Expand Down
6 changes: 5 additions & 1 deletion docs/guides/securing-apps/client-registration.adoc
8000
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,11 @@ Client scopes can contain role scope mappings and various protocol mappers, whic
When a client is assigned an allowed client scope, all of those role scope mappings and protocol mappers will be applied.

* Full Scope Policy - Newly registered clients will have `Full Scope Allowed` switch disabled. This means they won't have any scoped
realm roles or client roles of other clients.
realm roles or client roles of other clients. This policy is present by default for both anonymous and authenticated client registration.

* Default Roles Policy - Rejects create or update requests that would change the realm-wide default-role composite through the deprecated
`defaultRoles` field. This policy is present by default for both anonymous and authenticated client registration.
Realm administrators who still need this behaviour through Dynamic Client Registration can remove the policy.

* Max Clients Policy - Rejects registration if current number of clients in the realm is same or bigger than specified limit. It's 200 by default for anonymous registrations.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

import org.keycloak.Config;
import org.keycloak.component.ComponentModel;
import org.keycloak.migration.MigrationProvider;
import org.keycloak.migration.ModelVersion;
import org.keycloak.models.ClientModel;
Expand All @@ -14,10 +16,22 @@
8000 import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

/**
* Migrates realms for 26.8.2: Admin API client scope assignment, plus client registration
* policies that block {@code defaultRoles} and enable {@code Full Scope Disabled} for
* authenticated Dynamic Client Registration.
*/
public class MigrateTo26_8_2 extends RealmMigration {

public static final ModelVersion VERSION = new ModelVersion("26.8.2");

private static final String CLIENT_REGISTRATION_POLICY =
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy";
private static final String DEFAULT_ROLES_PROVIDER_ID = "default-roles";
private static final String SCOPE_PROVIDER_ID = "scope";
private static final String ANONYMOUS = "anonymous";
private static final String AUTHENTICATED = "authenticated";

@Override
public ModelVersion getVersion() {
return VERSION;
Expand All @@ -26,6 +40,9 @@ public ModelVersion getVersion() {
@Override
public void migrateRealm(KeycloakSession session, RealmModel realm) {
migrateAdminApiClientScope(session, realm);
addPolicyIfMissing(realm, "Default Roles Disabled", DEFAULT_ROLES_PROVIDER_ID, ANONYMOUS);
addPolicyIfMissing(realm, "Default Roles Disabled", DEFAULT_ROLES_PROVIDER_ID, AUTHENTICATED);
addPolicyIfMissing(realm, "Full Scope Disabled", SCOPE_PROVIDER_ID, AUTHENTICATED);
}

private void migrateAdminApiClientScope(KeycloakSession session, RealmModel realm) {
Expand Down Expand Up @@ -58,4 +75,21 @@ private void addAdminApiClientScope(ClientModel client, ClientScopeModel scope)
client.addClientScope(scope, true);
}
}

private static void addPolicyIfMissing(RealmModel realm, String name, String providerId, String policyType) {
boolean exists = realm.getComponentsStream(realm.getId(), CLIENT_REGISTRATION_POLICY)
.anyMatch(c -> Objects.equals(providerId, c.getProviderId())
&& Objects.equals(policyType, c.getSubType()));
if (exists) {
return;
}

ComponentModel model = new ComponentModel();
model.setName(name);
model.setParentId(realm.getId());
model.setProviderId(providerId);
model.setProviderType(CLIENT_REGISTRATION_POLICY);
model.setSubType(policyType);
realm.addComponentModel(model);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,6 @@ public ClientModel createClientMetadata(AbstractClientIdMetadataDocumentExecutor
session.clientPolicy().triggerOnEvent(new CimdClientRegisterContext(clientRep));
ClientModel clientModel = ClientManager.createClient(session, realm, clientRep);

if (clientRep.getDefaultRoles() != null) {
for (String name : clientRep.getDefaultRoles()) {
addDefaultRole(clientModel, name);
}
}

if (clientModel.isServiceAccountsEnabled()) {
new ClientManager(new RealmManager(session)).enableServiceAccount(clientModel);
}
Expand Down Expand Up @@ -274,20 +268,6 @@ private static ClientPolicyException invalidClientMetadata(String errorDetail) {
return new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, errorDetail);
}

// the same as AbstractClientRegistrationProvider.addDefaultRole
private void addDefaultRole(ClientModel client, String name) {
client.getRealm().getDefaultRole().addCompositeRole(getOrAddRoleId(client, name));
}

// the same as AbstractClientRegistrationProvider.getOrAddRoleId
private RoleModel getOrAddRoleId(ClientModel client, String name) {
RoleModel role = client.getRole(name);
if (role == null) {
role = client.addRole(name);
}
return role;
}

// the same as AbstractClientRegistrationProvider.getDefaultRolesStream
private Stream<String> getDefaultRolesStream(ClientModel client) {
return client.getRealm().getDefaultRole().getCompositesStream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -414,9 +414,10 @@ private void updateDefaultRoles(ClientModel client, String... defaultRoles) {
removeDefaultRoles(client, remove.toArray(new String[] {}));

for (String roleName : defaultRoles) {
if (!already.contains(roleName)) {
addDefaultRole(client, roleName);
< 8 /td> 10BC8 if (roleName == null || already.contains(roleName)) {
continue;
}
addDefaultRole(client, roleName);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.keycloak.protocol.saml.mappers.UserPropertyAttributeStatementMapper;
import org.keycloak.services.clientregistration.policy.impl.ClientScopesClientRegistrationPolicyFactory;
import org.keycloak.services.clientregistration.policy.impl.ConsentRequiredClientRegistrationPolicyFactory;
import org.keycloak.services.clientregistration.policy.impl.DefaultRolesClientRegistrationPolicyFactory;
import org.keycloak.services.clientregistration.policy.impl.MaxClientsClientRegistrationPolicyFactory;
import org.keycloak.services.clientregistration.policy.impl.ProtocolMappersClientRegistrationPolicyFactory;
import org.keycloak.services.clientregistration.policy.impl.RegistrationWebOriginsPolicyFactory;
Expand Down Expand Up @@ -99,10 +100,16 @@ private static void addAnonymousPolicies(RealmModel realm, String policyTypeKey)


private static void addAuthPolicies(RealmModel realm, String policyTypeKey) {
ComponentModel scopeModel = createModelInstance("Full Scope Disabled", realm, ScopeClientRegistrationPolicyFactory.PROVIDER_ID, policyTypeKey);
realm.addComponentModel(scopeModel);

addGenericPolicies(realm, policyTypeKey);
}

private static void addGenericPolicies(RealmModel realm, String policyTypeKey) {
ComponentModel defaultRolesModel = createModelInstance("Default Roles Disabled", realm, DefaultRolesClientRegistrationPolicyFactory.PROVIDER_ID, policyTypeKey);
realm.addComponentModel(defaultRolesModel);

ComponentModel protMapperModel = createModelInstance("Allowed Protocol Mapper Types", realm, ProtocolMappersClientRegistrationPolicyFactory.PROVIDER_ID, policyTypeKey);
protMapperModel.getConfig().put(ProtocolMappersClientRegistrationPolicyFactory.ALLOWED_PROTOCOL_MAPPER_TYPES, Arrays.asList(DEFAULT_ALLOWED_PROTOCOL_MAPPERS));
realm.addComponentModel(protMapperModel);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.services 10BC8 .clientregistration.policy.impl;

import java.util.Arrays;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

import org.keycloak.models.ClientModel;
import org.keycloak.models.RoleModel;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.clientregistration.ClientRegistrationContext;
import org.keycloak.services.clientregistration.ClientRegistrationProvider;
import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy;
import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicyException;

/**
* Rejects Dynamic Client Registration requests that would change the realm-wide default-role
* composite through the deprecated {@code defaultRoles} field.
*
*/
public class DefaultRolesClientRegistrationPolicy implements ClientRegistrationPolicy {

@Override
public void beforeRegister(ClientRegistrationContext context) throws ClientRegistrationPolicyException {
String[] defaultRoles = context.getClient().getDefaultRoles();
if (defaultRoles == null || defaultRoles.length == 0) {
return;
}
rejectNullDefaultRoles(defaultRoles);
throw new ClientRegistrationPolicyException("Not permitted to set defaultRoles");
}

@Override
public void afterRegister(ClientRegistrationContext context, ClientModel clientModel) {
}

@Override
public void beforeUpdate(ClientRegistrationContext context, ClientModel clientModel) throws ClientRegistrationPolicyException {
ClientRepresentation rep = context.getClient();
if (rep.getDefaultRoles() == null) {
return;
}
if (clientModel == null) {
return;
}

rejectNullDefaultRoles(rep.getDefaultRoles());

Set<String> current = getDefaultRoles(clientModel);
Set<String> requested = Arrays.stream(rep.getDefaultRoles()).collect(Collectors.toSet());
if (!current.equals(requested)) {
throw new ClientRegistrationPolicyException("Not permitted to modify defaultRoles");
}
}

private static void rejectNullDefaultRoles(String[] defaultRoles) throws ClientRegistrationPolicyException {
if (Arrays.stream(defaultRoles).anyMatch(Objects::isNull)) {
throw new ClientRegistrationPolicyException("Not permitted to set null defaultRoles");
}
}

@Override
public void afterUpdate(ClientRegistrationContext context, ClientModel clientModel) {
}

@Override
public void beforeView(ClientRegistrationProvider provider, ClientModel clientModel) throws ClientRegistrationPolicyException {
}

@Override
public void beforeDelete(ClientRegistrationProvider provider, ClientModel clientModel) throws ClientRegistrationPolicyException {
}

private static Set<String> getDefaultRoles(ClientModel client) {
return client.getRealm().getDefaultRole().getCompositesStream()
.filter(role -> role.isClientRole() && Objects.equals(role.getContainerId(), client.getId()))
.map(RoleModel::getName)
.collect(Collectors.toSet());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.services.clientregistration.policy.impl;

import java.util.Collections;
import java.util.List;

import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.clientregistration.policy.AbstractClientRegistrationPolicyFactory;
import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy;

public class DefaultRolesClientRegistrationPolicyFactory extends AbstractClientRegistrationPolicyFactory {

public static final String PROVIDER_ID = "default-roles";

@Override
public ClientRegistrationPolicy create(KeycloakSession session, ComponentModel model) {
return new DefaultRolesClientRegistrationPolicy();
}

@Override
public String getHelpText() {
return "When present, then Dynamic Client Registration cannot change the realm default roles through the deprecated defaultRoles field";
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}

@Override
public String getId() {
return PROVIDER_ID;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ org.keycloak.services.clientregistration.policy.impl.ConsentRequiredClientRegist
org.keycloak.services.clientregistration.policy.impl.ProtocolMappersClientRegistrationPolicyFactory
org.keycloak.services.clientregistration.policy.impl.ClientScopesClientRegistrationPolicyFactory
org.keycloak.services.clientregistration.policy.impl.ScopeClientRegistrationPolicyFactory
org.keycloak.services.clientregistration.policy.impl.DefaultRolesClientRegistrationPolicyFactory
org.keycloak.services.clientregistration.policy.impl.ClientDisabledClientRegistrationPolicyFactory
org.keycloak.services.clientregistration.policy.impl.MaxClientsClientRegistrationPolicyFactory
org.keycloak.services.clientregistration.policy.impl.RegistrationWebOriginsPolicyFactory
Loading
Loading
0