Added ClientType implementation from Marek's prototype

Signed-off-by: vibrown <vibrown@redhat.com>

More updates

Signed-off-by: vibrown <vibrown@redhat.com>

Added client type logic from Marek's prototype

Signed-off-by: vibrown <vibrown@redhat.com>

updates

Signed-off-by: vibrown <vibrown@redhat.com>

updates

Signed-off-by: vibrown <vibrown@redhat.com>

updates

Signed-off-by: vibrown <vibrown@redhat.com>

Testing to see if skipRestart was cause of test failures in MR
This commit is contained in:
vibrown 2024-02-05 11:06:31 -06:00 committed by Marek Posolda
parent 9c1790af68
commit 3fffc5182e
20 changed files with 664 additions and 122 deletions

View file

@ -31,6 +31,7 @@ public class ClientRepresentation {
protected String clientId;
protected String name;
protected String description;
protected String type;
protected String rootUrl;
protected String adminUrl;
protected String baseUrl;
@ -105,6 +106,14 @@ public class ClientRepresentation {
this.description = description;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getClientId() {
return clientId;
}

View file

@ -18,7 +18,6 @@
package org.keycloak.representations.idm;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonProperty;
@ -61,17 +60,6 @@ public class ClientTypeRepresentation {
this.config = config;
}
@JsonProperty("referenced-properties")
protected Map<String, Object> referencedProperties = new HashMap<>();
public Map<String, Object> getReferencedProperties() {
return referencedProperties;
}
public void setReferencedProperties(Map<String, Object> referencedProperties) {
this.referencedProperties = referencedProperties;
}
public static class PropertyConfig {
@JsonProperty("applicable")

View file

@ -0,0 +1,49 @@
/*
* Copyright 2021 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.admin.client.resource;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.keycloak.representations.idm.ClientTypesRepresentation;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public interface ClientTypesResource {
@GET
@NoCache
@Produces(MediaType.APPLICATION_JSON)
ClientTypesRepresentation getClientTypes();
/**
* Update client types in the realm. The "global-client-types" field of client types is ignored as it is not possible to update global types
*
* @param clientTypes
*/
@PUT
@Consumes(MediaType.APPLICATION_JSON)
void updateClientTypes(final ClientTypesRepresentation clientTypes);
}

View file

@ -292,4 +292,7 @@ public interface RealmResource {
@Path("organizations")
OrganizationsResource organizations();
@Path("client-types")
ClientTypesResource clientTypes();
}

View file

@ -18,7 +18,9 @@
package org.keycloak.models.cache.infinispan;
import org.jboss.logging.Logger;
import org.keycloak.client.clienttype.ClientTypeManager;
import org.keycloak.cluster.ClusterProvider;
import org.keycloak.common.Profile;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.*;
import org.keycloak.models.cache.CacheRealmProvider;
@ -1195,7 +1197,7 @@ public class RealmCacheSession implements CacheRealmProvider {
if (invalidations.contains(delegate.getId())) return delegate;
StorageId storageId = new StorageId(delegate.getId());
CachedClient cached = null;
ClientAdapter adapter = null;
ClientModel adapter = null;
if (!storageId.isLocal()) {
ComponentModel component = realm.getComponent(storageId.getProviderId());
@ -1209,7 +1211,7 @@ public class RealmCacheSession implements CacheRealmProvider {
}
cached = new CachedClient(revision, realm, delegate);
adapter = new ClientAdapter(realm, cached, this);
adapter = toClientModel(realm, cached);
long lifespan = model.getLifespan();
if (lifespan > 0) {
@ -1219,7 +1221,7 @@ public class RealmCacheSession implements CacheRealmProvider {
}
} else {
cached = new CachedClient(revision, realm, delegate);
adapter = new ClientAdapter(realm, cached, this);
adapter = toClientModel(realm, cached);
cache.addRevisioned(cached, startupRevision);
}
@ -1247,9 +1249,17 @@ public class RealmCacheSession implements CacheRealmProvider {
return getClientDelegate().getClientById(realm, cached.getId());
}
}
ClientAdapter adapter = new ClientAdapter(realm, cached, this);
return toClientModel(realm, cached);
}
return adapter;
private ClientModel toClientModel(RealmModel realm, CachedClient cached) {
ClientAdapter client = new ClientAdapter(realm, cached, this);
if (Profile.isFeatureEnabled(Profile.Feature.CLIENT_TYPES)) {
ClientTypeManager mgr = session.getProvider(ClientTypeManager.class);
return mgr.augmentClient(client);
}
return client;
}
@Override

View file

@ -43,6 +43,8 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.hibernate.Session;
import org.jboss.logging.Logger;
import org.keycloak.client.clienttype.ClientTypeManager;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.connections.jpa.util.JpaUtils;
import org.keycloak.migration.MigrationModel;
@ -293,10 +295,11 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
.filter(s -> s.get("client") != null))
.collect(
Collectors.groupingBy(
s -> new ClientAdapter(realm, em, session, (ClientEntity) s.get("client")),
s -> toClientModel(realm, (ClientEntity) s.get("client")),
Collectors.mapping(s -> (String) s.get("redirectUri"), Collectors.toSet())
)
);
}
@Override
@ -755,6 +758,8 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
@Override
public ClientModel addClient(RealmModel realm, String id, String clientId) {
ClientModel resource;
if (id == null) {
id = KeycloakModelUtils.generateId();
}
@ -773,7 +778,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
entity.setRealmId(realm.getId());
em.persist(entity);
final ClientModel resource = new ClientAdapter(realm, em, session, entity);
resource = toClientModel(realm, entity);
session.getKeycloakSessionFactory().publish((ClientModel.ClientCreationEvent) () -> resource);
return resource;
@ -810,9 +815,18 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
ClientEntity client = em.find(ClientEntity.class, id);
// Check if client belongs to this realm
if (client == null || !realm.getId().equals(client.getRealmId())) return null;
ClientAdapter adapter = new ClientAdapter(realm, em, session, client);
return adapter;
return toClientModel(realm, client);
}
private ClientModel toClientModel(RealmModel realm, ClientEntity client) {
ClientAdapter adapter = new ClientAdapter(realm, em, session, client);
if (Profile.isFeatureEnabled(Profile.Feature.CLIENT_TYPES)) {
ClientTypeManager mgr = session.getProvider(ClientTypeManager.class);
return mgr.augmentClient(adapter);
} else {
return adapter;
}
}
@Override

View file

@ -34,7 +34,7 @@ public class ClientTypeSpi implements Spi {
@Override
public String getName() {
return "client-type";
return "clientType";
}
@Override

View file

@ -119,6 +119,11 @@ public class ModelToRepresentation {
REALM_EXCLUDED_ATTRIBUTES.add("firstBrokerLoginFlowId");
}
public static Set<String> CLIENT_EXCLUDED_ATTRIBUTES = new HashSet<>();
static {
CLIENT_EXCLUDED_ATTRIBUTES.add(ClientModel.TYPE);
}
private static final Logger LOG = Logger.getLogger(ModelToRepresentation.class);
public static String buildGroupPath(GroupModel group) {
@ -536,6 +541,20 @@ public class ModelToRepresentation {
return a;
}
public static Map<String, String> stripClientAttributesIncludedAsFields(Map<String, String> attributes) {
Map<String, String> a = new HashMap<>();
for (Map.Entry<String, String> e : attributes.entrySet()) {
if (CLIENT_EXCLUDED_ATTRIBUTES.contains(e.getKey())) {
continue;
}
a.put(e.getKey(), e.getValue());
}
return a;
}
public static void exportGroups(KeycloakSession session, RealmModel realm, RealmRepresentation rep) {
rep.setGroups(toGroupHierarchy(session, realm, true).collect(Collectors.toList()));
}
@ -686,13 +705,14 @@ public class ModelToRepresentation {
rep.setClientId(clientModel.getClientId());
rep.setName(clientModel.getName());
rep.setDescription(clientModel.getDescription());
rep.setType(clientModel.getType());
rep.setEnabled(clientModel.isEnabled());
rep.setAlwaysDisplayInConsole(clientModel.isAlwaysDisplayInConsole());
rep.setAdminUrl(clientModel.getManagementUrl());
rep.setPublicClient(clientModel.isPublicClient());
rep.setFrontchannelLogout(clientModel.isFrontchannelLogout());
rep.setProtocol(clientModel.getProtocol());
rep.setAttributes(clientModel.getAttributes());
rep.setAttributes(stripClientAttributesIncludedAsFields(clientModel.getAttributes()));
rep.setAuthenticationFlowBindingOverrides(clientModel.getAuthenticationFlowBindingOverrides());
rep.setFullScopeAllowed(clientModel.isFullScopeAllowed());
rep.setBearerOnly(clientModel.isBearerOnly());

View file

@ -319,6 +319,7 @@ public class RepresentationToModel {
ClientModel client = resourceRep.getId() != null ? realm.addClient(resourceRep.getId(), resourceRep.getClientId()) : realm.addClient(resourceRep.getClientId());
if (resourceRep.getName() != null) client.setName(resourceRep.getName());
if (resourceRep.getDescription() != null) client.setDescription(resourceRep.getDescription());
if (resourceRep.getType() != null) client.setType(resourceRep.getType());
if (resourceRep.isEnabled() != null) client.setEnabled(resourceRep.isEnabled());
if (resourceRep.isAlwaysDisplayInConsole() != null) client.setAlwaysDisplayInConsole(resourceRep.isAlwaysDisplayInConsole());
client.setManagementUrl(resourceRep.getAdminUrl());
@ -493,6 +494,7 @@ public class RepresentationToModel {
if (newClientId != null) resource.setClientId(newClientId);
if (rep.getName() != null) resource.setName(rep.getName());
if (rep.getDescription() != null) resource.setDescription(rep.getDescription());
if (rep.getType() != null) resource.setType(rep.getType());
if (rep.isEnabled() != null) resource.setEnabled(rep.isEnabled());
if (rep.isAlwaysDisplayInConsole() != null) resource.setAlwaysDisplayInConsole(rep.isAlwaysDisplayInConsole());
if (rep.isBearerOnly() != null) resource.setBearerOnly(rep.isBearerOnly());

View file

@ -39,6 +39,7 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
String LOGO_URI ="logoUri";
String POLICY_URI ="policyUri";
String TOS_URI ="tosUri";
String TYPE = "type";
interface ClientCreationEvent extends ProviderEvent {
ClientModel getCreatedClient();
@ -105,6 +106,14 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
void setDescription(String description);
default String getType() {
return getAttribute(TYPE);
}
default void setType(String type) {
setAttribute(TYPE, type);
}
boolean isEnabled();
void setEnabled(boolean enabled);

View file

@ -34,6 +34,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.ClientTypeRepresentation;
import org.keycloak.representations.idm.ClientTypesRepresentation;
import org.keycloak.services.clienttype.client.TypeAwareClientModelDelegate;
import org.keycloak.util.JsonSerialization;
/**
@ -66,6 +67,7 @@ public class DefaultClientTypeManager implements ClientTypeManager {
try {
// Skip validation here for performance reasons
result = JsonSerialization.readValue(asStr, ClientTypesRepresentation.class);
result.setGlobalClientTypes(globalClientTypes);
} catch (IOException ioe) {
throw new ClientTypeException("Failed to deserialize client types from JSON string", ioe);
}
@ -104,14 +106,12 @@ public class DefaultClientTypeManager implements ClientTypeManager {
@Override
public ClientModel augmentClient(ClientModel client) throws ClientTypeException {
//TODO:vibrown put the logic back in next Client Type PR
return client;
/*if (client.getType() == null) {
if (client.getType() == null) {
return client;
} else {
ClientType clientType = getClientType(client.getRealm(), client.getType());
return new TypeAwareClientModelDelegate(clientType, () -> client);
}*/
}
}
static List<ClientTypeRepresentation> validateAndCastConfiguration(KeycloakSession session, List<ClientTypeRepresentation> clientTypes, List<ClientTypeRepresentation> globalTypes) {
@ -129,8 +129,8 @@ public class DefaultClientTypeManager implements ClientTypeManager {
private static ClientTypeRepresentation validateAndCastConfiguration(KeycloakSession session, ClientTypeRepresentation clientType, Set<String> currentNames) {
ClientTypeProvider clientTypeProvider = session.getProvider(ClientTypeProvider.class, clientType.getProvider());
if (clientTypeProvider == null) {
logger.errorf("Did not found client type provider '%s' for the client type '%s'", clientType.getProvider(), clientType.getName());
throw new ClientTypeException("Did not found client type provider");
logger.errorf("Did not find client type provider '%s' for the client type '%s'", clientType.getProvider(), clientType.getName());
throw new ClientTypeException("Did not find client type provider");
}
// Validate name is not duplicated

View file

@ -0,0 +1,91 @@
/*
* Copyright 2021 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.clienttype.client;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.models.ClientModel;
import org.keycloak.models.delegate.ClientModelLazyDelegate;
import org.keycloak.client.clienttype.ClientType;
import org.keycloak.client.clienttype.ClientTypeException;
/**
* Delegates to client-type and underlying delegate
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class TypeAwareClientModelDelegate extends ClientModelLazyDelegate {
private final ClientType clientType;
public TypeAwareClientModelDelegate(ClientType clientType, Supplier<ClientModel> clientModelSupplier) {
super(clientModelSupplier);
if (clientType == null) {
throw new IllegalArgumentException("Null client type not supported for client " + getClientId());
}
this.clientType = clientType;
}
@Override
public boolean isStandardFlowEnabled() {
return getBooleanProperty("standardFlowEnabled", super::isStandardFlowEnabled);
}
@Override
public void setStandardFlowEnabled(boolean standardFlowEnabled) {
setBooleanProperty("standardFlowEnabled", standardFlowEnabled, super::setStandardFlowEnabled);
}
protected boolean getBooleanProperty(String propertyName, Supplier<Boolean> clientGetter) {
// Check if clientType supports the feature. If not, simply return false
if (!clientType.isApplicable(propertyName)) {
return false;
}
// Check if this is read-only. If yes, then we just directly delegate to return stuff from the clientType rather than from client
if (clientType.isReadOnly(propertyName)) {
return clientType.getDefaultValue(propertyName, Boolean.class);
}
// Delegate to clientGetter
return clientGetter.get();
}
protected void setBooleanProperty(String propertyName, Boolean newValue, Consumer<Boolean> clientSetter) {
// Check if clientType supports the feature. If not, return directly
if (!clientType.isApplicable(propertyName)) {
return;
}
// Check if this is read-only. If yes and there is an attempt to change some stuff, then throw an exception
if (clientType.isReadOnly(propertyName)) {
Boolean oldVal = clientType.getDefaultValue(propertyName, Boolean.class);
if (!ObjectUtil.isEqualOrBothNull(oldVal, newValue)) {
throw new ClientTypeException("Property " + propertyName + " of client " + getClientId() + " is read-only due to client type " + clientType.getName());
}
}
// Call clientSetter
clientSetter.accept(newValue);
}
}

View file

@ -20,11 +20,9 @@ package org.keycloak.services.clienttype.impl;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JavaType;
import org.jboss.logging.Logger;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.models.ClientModel;
@ -34,7 +32,6 @@ import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientTypeRepresentation;
import org.keycloak.client.clienttype.ClientType;
import org.keycloak.client.clienttype.ClientTypeException;
import org.keycloak.util.JsonSerialization;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -43,9 +40,6 @@ public class DefaultClientType implements ClientType {
private static final Logger logger = Logger.getLogger(DefaultClientType.class);
// Will be used as reference in JSON. Probably just temporary solution
private static final String REFERENCE_PREFIX = "ref::";
private final KeycloakSession session;
private final ClientTypeRepresentation clientType;
@ -93,40 +87,9 @@ public class DefaultClientType implements ClientType {
if (propertyConfig.getDefaultValue() != null) {
if (clientRepresentationProperties.containsKey(property.getKey())) {
// Java property on client representation
Method setter = clientRepresentationProperties.get(property.getKey()).getWriteMethod();
try {
PropertyDescriptor propertyDescriptor = clientRepresentationProperties.get(property.getKey());
Method setter = propertyDescriptor.getWriteMethod();
Object defaultVal = propertyConfig.getDefaultValue();
if (defaultVal instanceof String && defaultVal.toString().startsWith(REFERENCE_PREFIX)) {
// TODO:client-types re-verify or remove support for "ref::" entirely from the codebase
throw new UnsupportedOperationException("Not supported to use ref:: references");
// Reference. We need to found referred value and call the setter with it
// String referredPropertyName = defaultVal.toString().substring(REFERENCE_PREFIX.length());
// Object referredPropertyVal = clientType.getReferencedProperties().get(referredPropertyName);
// if (referredPropertyVal == null) {
// logger.warnf("Reference '%s' not found used in property '%s' of client type '%s'", defaultVal.toString(), property.getKey(), clientType.getName());
// throw new ClientTypeException("Cannot set property on client");
// }
//
// // Generic collections
// Type genericType = setter.getGenericParameterTypes()[0];
// JavaType jacksonType = JsonSerialization.mapper.constructType(genericType);
// Object converted = JsonSerialization.mapper.convertValue(referredPropertyVal, jacksonType);
//
// setter.invoke(createdClient, converted);
} else {
Type genericType = setter.getGenericParameterTypes()[0];
Object converted;
if (!defaultVal.getClass().equals(genericType)) {
JavaType jacksonType = JsonSerialization.mapper.constructType(genericType);
converted = JsonSerialization.mapper.convertValue(defaultVal, jacksonType);
} else {
converted = defaultVal;
}
setter.invoke(createdClient, converted);
}
setter.invoke(createdClient, propertyConfig.getDefaultValue());
} catch (Exception e) {
logger.warnf("Cannot set property '%s' on client with value '%s'. Check configuration of the client type '%s'", property.getKey(), propertyConfig.getDefaultValue(), clientType.getName());
throw new ClientTypeException("Cannot set property on client", e);

View file

@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import org.jboss.logging.Logger;
import org.keycloak.authentication.ClientAuthenticator;
import org.keycloak.authentication.ClientAuthenticatorFactory;
import org.keycloak.common.Profile;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.Time;
import org.keycloak.models.ClientModel;
@ -41,6 +42,8 @@ import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.adapters.config.BaseRealmConfig;
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.client.clienttype.ClientType;
import org.keycloak.client.clienttype.ClientTypeManager;
import org.keycloak.sessions.AuthenticationSessionProvider;
import java.net.URI;
@ -79,6 +82,12 @@ public class ClientManager {
* @return
*/
public static ClientModel createClient(KeycloakSession session, RealmModel realm, ClientRepresentation rep) {
if (Profile.isFeatureEnabled(Profile.Feature.CLIENT_TYPES) && rep.getType() != null) {
ClientTypeManager mgr = session.getProvider(ClientTypeManager.class);
ClientType clientType = mgr.getClientType(realm, rep.getType());
clientType.onCreate(rep);
}
ClientModel client = RepresentationToModel.createClient(session, realm, rep);
if (rep.getProtocol() != null) {
@ -164,7 +173,13 @@ public class ClientManager {
user.setServiceAccountClientLink(client.getId());
}
// Add protocol mappers to retrieve clientId in access token
// Add protocol mappers to retrieve clientId in access token. Ignore this in case type is filled (protocol mappers can be explicitly specified for particular specific type)
if (!Profile.isFeatureEnabled(Profile.Feature.CLIENT_TYPES) || client.getType() == null) {
addServiceAccountProtocolMappers(client);
}
}
private void addServiceAccountProtocolMappers(ClientModel client) {
if (client.getProtocolMapperByName(OIDCLoginProtocol.LOGIN_PROTOCOL, ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER) == null) {
logger.debugf("Creating service account protocol mapper '%s' for client '%s'", ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER, client.getClientId());
ProtocolMapperModel protocolMapper = UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER,

View file

@ -26,8 +26,12 @@ import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.OAuthErrorException;
import org.keycloak.authorization.admin.AuthorizationService;
import org.keycloak.client.clienttype.ClientType;
import org.keycloak.client.clienttype.ClientTypeException;
import org.keycloak.client.clienttype.ClientTypeManager;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.common.util.Time;
import org.keycloak.events.Errors;
import org.keycloak.events.admin.OperationType;
@ -148,6 +152,17 @@ public class ClientResource {
session.setAttribute(ClientSecretConstants.CLIENT_SECRET_ROTATION_ENABLED,Boolean.FALSE);
session.clientPolicy().triggerOnEvent(new AdminClientUpdateContext(rep, client, auth.adminAuth()));
if (Profile.isFeatureEnabled(Profile.Feature.CLIENT_TYPES)) {
if (!ObjectUtil.isEqualOrBothNull(rep.getType(), client.getType())) {
throw new ClientTypeException("Not supported to change client type");
}
if (rep.getType() != null) {
ClientTypeManager mgr = session.getProvider(ClientTypeManager.class);
ClientType clientType = mgr.getClientType(realm, rep.getType());
clientType.onUpdate(client, rep);
}
}
updateClientFromRep(rep, client, session);
ValidationUtil.validateClient(session, client, false, r -> {
@ -170,6 +185,8 @@ public class ClientResource {
return Response.noContent().build();
} catch (ModelDuplicateException e) {
throw ErrorResponse.exists("Client already exists");
} catch (ClientTypeException cte) {
throw ErrorResponse.error(cte.getMessage(), Response.Status.BAD_REQUEST);
} catch (ClientPolicyException cpe) {
throw new ErrorResponseException(cpe.getError(), cpe.getErrorDetail(), Response.Status.BAD_REQUEST);
}

View file

@ -0,0 +1,97 @@
/*
* Copyright 2021 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.resources.admin;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.ClientTypesRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.client.clienttype.ClientTypeException;
import org.keycloak.client.clienttype.ClientTypeManager;
import org.keycloak.services.resources.KeycloakOpenAPI;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@Extension(name = KeycloakOpenAPI.Profiles.ADMIN, value = "")
public class ClientTypesResource {
protected static final Logger logger = Logger.getLogger(ClientTypesResource.class);
protected final ClientTypeManager manager;
protected final RealmModel realm;
private final AdminPermissionEvaluator auth;
public ClientTypesResource(ClientTypeManager manager, RealmModel realm, AdminPermissionEvaluator auth) {
this.manager = manager;
this.auth = auth;
this.realm = realm;
}
@GET
@NoCache
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.REALMS_ADMIN)
@Operation(summary = "List all client types available in the current realm",
description = "This endpoint returns a list of both global and realm level client types and the attributes they set"
)
public ClientTypesRepresentation getClientTypes() {
auth.realm().requireViewRealm();
try {
return manager.getClientTypes(realm);
} catch (ClientTypeException e) {
logger.error(e.getMessage(), e);
throw new BadRequestException(ErrorResponse.error(e.getMessage(), Response.Status.BAD_REQUEST));
}
}
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.REALMS_ADMIN)
@Operation(summary = "Update a client type",
description = "This endpoint allows you to update a realm level client type"
)
@APIResponse(responseCode = "204", description = "No Content")
public Response updateClientTypes(final ClientTypesRepresentation clientTypes) {
auth.realm().requireManageRealm();
try {
manager.updateClientTypes(realm, clientTypes);
} catch (ClientTypeException e) {
logger.error(e.getMessage(), e);
throw ErrorResponse.error(e.getMessage(), Response.Status.BAD_REQUEST);
}
return Response.noContent().build();
}
}

View file

@ -63,6 +63,7 @@ import org.keycloak.Config;
import org.keycloak.KeyPairVerifier;
import org.keycloak.authentication.CredentialRegistrator;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.client.clienttype.ClientTypeManager;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.VerificationException;
@ -1228,4 +1229,11 @@ public class RealmAdminResource {
ProfileHelper.requireFeature(Profile.Feature.CLIENT_POLICIES);
return new ClientProfilesResource(session, auth);
}
@Path("client-types")
public ClientTypesResource getClientTypesResource() {
ProfileHelper.requireFeature(Profile.Feature.CLIENT_TYPES);
return new ClientTypesResource(session.getProvider(ClientTypeManager.class), realm, auth);
}
}

View file

@ -61,53 +61,6 @@
"read-only": true,
"default-value": true
},
"protocolMappers": {
"applicable": true,
"read-only": true,
"default-value": [
{
"name" : "Client IP Address",
"protocol" : "openid-connect",
"protocolMapper" : "oidc-usersessionmodel-note-mapper",
"consentRequired" : false,
"config" : {
"user.session.note" : "clientAddress",
"id.token.claim" : "true",
"access.token.claim" : "true",
"claim.name" : "clientAddress",
"jsonType.label" : "String"
}
},
{
"name" : "Client Host",
"protocol" : "openid-connect",
"protocolMapper" : "oidc-usersessionmodel-note-mapper",
"consentRequired" : false,
"config" : {
"user.session.note" : "clientHost",
"id.token.claim" : "true",
"access.token.claim" : "true",
"claim.name" : "clientHost",
"jsonType.label" : "String"
}
}
]
},
"webOrigins": {
"applicable": true,
"read-only": true,
"default-value": [ "https://foo", "https://bar"]
},
"defaultClientScopes": {
"applicable": true,
"read-only": true,
"default-value": [ "address", "offline_access"]
},
"optionalClientScopes": {
"applicable": true,
"read-only": true,
"default-value": [ "profile" ]
},
"logoUri": {
"applicable": false
},

View file

@ -0,0 +1,289 @@
/*
* Copyright 2021 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.testsuite.client;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.core.Response;
import org.junit.Test;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.models.ClientModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientTypeRepresentation;
import org.keycloak.representations.idm.ClientTypesRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.client.clienttype.ClientTypeManager;
import org.keycloak.services.clienttype.impl.DefaultClientTypeProviderFactory;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
import org.keycloak.testsuite.util.ClientBuilder;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.fail;
import static org.keycloak.common.Profile.Feature.CLIENT_TYPES;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@EnableFeature(value = CLIENT_TYPES)
public class ClientTypesTest extends AbstractTestRealmKeycloakTest {
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
}
@Test
public void testFeatureWorksWhenEnabled() {
checkIfFeatureWorks(true);
}
@Test
@UncaughtServerErrorExpected
@DisableFeature(value = CLIENT_TYPES, skipRestart = true)
public void testFeatureDoesntWorkWhenDisabled() {
checkIfFeatureWorks(false);
}
// Test create client with clientType filled. Check default properties are filled
@Test
public void testCreateClientWithClientType() {
ClientRepresentation clientRep = createClientWithType("foo", ClientTypeManager.SERVICE_ACCOUNT);
Assert.assertEquals("foo", clientRep.getClientId());
Assert.assertEquals(ClientTypeManager.SERVICE_ACCOUNT, clientRep.getType());
Assert.assertEquals(OIDCLoginProtocol.LOGIN_PROTOCOL, clientRep.getProtocol());
Assert.assertFalse(clientRep.isStandardFlowEnabled());
Assert.assertFalse(clientRep.isImplicitFlowEnabled());
Assert.assertFalse(clientRep.isDirectAccessGrantsEnabled());
Assert.assertTrue(clientRep.isServiceAccountsEnabled());
Assert.assertFalse(clientRep.isPublicClient());
Assert.assertFalse(clientRep.isBearerOnly());
// Check type not included as client attribute
Assert.assertFalse(clientRep.getAttributes().containsKey(ClientModel.TYPE));
}
@Test
public void testUpdateClientWithClientType() {
ClientRepresentation clientRep = createClientWithType("foo", ClientTypeManager.SERVICE_ACCOUNT);
// Changing type should fail
clientRep.setType(ClientTypeManager.STANDARD);
try {
testRealm().clients().get(clientRep.getId()).update(clientRep);
Assert.fail("Not expected to update client");
} catch (BadRequestException bre) {
// Expected
}
// Updating read-only attribute should fail
clientRep.setType(ClientTypeManager.SERVICE_ACCOUNT);
clientRep.setServiceAccountsEnabled(false);
try {
testRealm().clients().get(clientRep.getId()).update(clientRep);
Assert.fail("Not expected to update client");
} catch (BadRequestException bre) {
// Expected
}
// Adding non-applicable attribute should fail
clientRep.setServiceAccountsEnabled(true);
clientRep.getAttributes().put(ClientModel.LOGO_URI, "https://foo");
try {
testRealm().clients().get(clientRep.getId()).update(clientRep);
Assert.fail("Not expected to update client");
} catch (BadRequestException bre) {
// Expected
}
// Update of supported attribute should be successful
clientRep.getAttributes().remove(ClientModel.LOGO_URI);
clientRep.setRootUrl("https://foo");
testRealm().clients().get(clientRep.getId()).update(clientRep);
}
@Test
public void testClientTypesAdminRestAPI_globalTypes() {
ClientTypesRepresentation clientTypes = testRealm().clientTypes().getClientTypes();
Assert.assertEquals(0, clientTypes.getRealmClientTypes().size());
List<String> globalClientTypeNames = clientTypes.getGlobalClientTypes().stream()
.map(ClientTypeRepresentation::getName)
.collect(Collectors.toList());
Assert.assertNames(globalClientTypeNames, "sla", "service-account");
ClientTypeRepresentation serviceAccountType = clientTypes.getGlobalClientTypes().stream()
.filter(clientType -> "service-account".equals(clientType.getName()))
.findFirst()
.get();
Assert.assertEquals("default", serviceAccountType.getProvider());
ClientTypeRepresentation.PropertyConfig cfg = serviceAccountType.getConfig().get("standardFlowEnabled");
assertPropertyConfig("standardFlowEnabled", cfg, true, true, false);
cfg = serviceAccountType.getConfig().get("serviceAccountsEnabled");
assertPropertyConfig("serviceAccountsEnabled", cfg, true, true, true);
cfg = serviceAccountType.getConfig().get("tosUri");
assertPropertyConfig("tosUri", cfg, false, null, null);
}
@Test
public void testClientTypesAdminRestAPI_realmTypes() {
ClientTypesRepresentation clientTypes = testRealm().clientTypes().getClientTypes();
// Test invalid provider type should fail
ClientTypeRepresentation clientType = new ClientTypeRepresentation();
try {
clientType.setName("sla1");
clientType.setProvider("non-existent");
clientType.setConfig(new HashMap<String, ClientTypeRepresentation.PropertyConfig>());
clientTypes.setRealmClientTypes(Arrays.asList(clientType));
testRealm().clientTypes().updateClientTypes(clientTypes);
Assert.fail("Not expected to update client types");
} catch (BadRequestException bre) {
// Expected
}
// Test attribute without applicable should fail
try {
clientType.setProvider(DefaultClientTypeProviderFactory.PROVIDER_ID);
ClientTypeRepresentation.PropertyConfig cfg = new ClientTypeRepresentation.PropertyConfig();
clientType.getConfig().put("standardFlowEnabled", cfg);
testRealm().clientTypes().updateClientTypes(clientTypes);
Assert.fail("Not expected to update client types");
} catch (BadRequestException bre) {
// Expected
}
// Test non-applicable attribute with default-value should fail
try {
ClientTypeRepresentation.PropertyConfig cfg = clientType.getConfig().get("standardFlowEnabled");
cfg.setApplicable(false);
cfg.setReadOnly(true);
cfg.setDefaultValue(true);
testRealm().clientTypes().updateClientTypes(clientTypes);
Assert.fail("Not expected to update client types");
} catch (BadRequestException bre) {
// Expected
}
// Update should be successful
ClientTypeRepresentation.PropertyConfig cfg = clientType.getConfig().get("standardFlowEnabled");
cfg.setApplicable(true);
testRealm().clientTypes().updateClientTypes(clientTypes);
// Test duplicate name should fail
ClientTypeRepresentation clientType2 = new ClientTypeRepresentation();
try {
clientTypes = testRealm().clientTypes().getClientTypes();
clientType2 = new ClientTypeRepresentation();
clientType2.setName("sla1");
clientType2.setProvider(DefaultClientTypeProviderFactory.PROVIDER_ID);
clientType2.setConfig(new HashMap<>());
clientTypes.getRealmClientTypes().add(clientType2);
testRealm().clientTypes().updateClientTypes(clientTypes);
Assert.fail("Not expected to update client types");
} catch (BadRequestException bre) {
// Expected
}
// Also test duplicated global name should fail
try {
clientType2.setName("service-account");
testRealm().clientTypes().updateClientTypes(clientTypes);
Assert.fail("Not expected to update client types");
} catch (BadRequestException bre) {
// Expected
}
// Different name should be fine
clientType2.setName("different");
testRealm().clientTypes().updateClientTypes(clientTypes);
// Assert updated
clientTypes = testRealm().clientTypes().getClientTypes();
assertNames(clientTypes.getRealmClientTypes(), "sla1", "different");
assertNames(clientTypes.getGlobalClientTypes(), "sla", "service-account");
// Test updating global won't update anything. Nothing will be added to globalTypes
clientType2.setName("moreDifferent");
clientTypes.getGlobalClientTypes().add(clientType2);
testRealm().clientTypes().updateClientTypes(clientTypes);
clientTypes = testRealm().clientTypes().getClientTypes();
assertNames(clientTypes.getRealmClientTypes(), "sla1", "different");
assertNames(clientTypes.getGlobalClientTypes(), "sla", "service-account");
}
private void assertNames(List<ClientTypeRepresentation> clientTypes, String... expectedNames) {
List<String> names = clientTypes.stream()
.map(ClientTypeRepresentation::getName)
.collect(Collectors.toList());
Assert.assertNames(names, expectedNames);
}
private void assertPropertyConfig(String propertyName, ClientTypeRepresentation.PropertyConfig cfg, Boolean expectedApplicable, Boolean expectedReadOnly, Object expectedDefaultValue) {
assertThat("'applicable' for property " + propertyName + " not equal", ObjectUtil.isEqualOrBothNull(expectedApplicable, cfg.getApplicable()));
assertThat("'read-only' for property " + propertyName + " not equal", ObjectUtil.isEqualOrBothNull(expectedReadOnly, cfg.getReadOnly()));
assertThat("'default-value' ;for property " + propertyName + " not equal", ObjectUtil.isEqualOrBothNull(expectedDefaultValue, cfg.getDefaultValue()));
}
private ClientRepresentation createClientWithType(String clientId, String clientType) {
ClientRepresentation clientRep = ClientBuilder.create()
.clientId(clientId)
.type(clientType)
.build();
Response response = testRealm().clients().create(clientRep);
String clientUUID = ApiUtil.getCreatedId(response);
getCleanup().addClientUuid(clientUUID);
return testRealm().clients().get(clientUUID).toRepresentation();
}
// Check if the feature really works
private void checkIfFeatureWorks(boolean shouldWork) {
try {
ClientTypesRepresentation clientTypes = testRealm().clientTypes().getClientTypes();
Assert.assertTrue(clientTypes.getRealmClientTypes().isEmpty());
if (!shouldWork)
fail("Feature is available, but at this moment should be disabled");
} catch (Exception e) {
if (shouldWork) {
e.printStackTrace();
fail("Feature is not available");
}
}
}
}

View file

@ -63,6 +63,11 @@ public class ClientBuilder {
return this;
}
public ClientBuilder type(String type) {
rep.setType(type);
return this;
}
public ClientBuilder consentRequired(boolean consentRequired) {
rep.setConsentRequired(consentRequired);
return this;