Initial code for SCIM core and testsuite (#45978)

Closes #45712

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor
2026-02-23 14:22:25 -03:00
committed by GitHub
parent 27da1c6d0f
commit 3e3a7befd1
118 changed files with 5764 additions and 15 deletions
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-parent</artifactId>
<groupId>org.keycloak</groupId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-scim-client</artifactId>
<name>Keycloak SCIM Client</name>
<description>
This module provides a SCIM Client API based on the SCIM Protocol(RFC7644)
</description>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-scim-core</artifactId>
</dependency>
<!-- Resteasy Client -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-client</artifactId>
<version>${resteasy.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-multipart-provider</artifactId>
<version>${resteasy.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jackson2-provider</artifactId>
<version>${resteasy.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jaxb-provider</artifactId>
<version>${resteasy.version}</version>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,77 @@
package org.keycloak.scim.client;
import org.keycloak.http.simple.SimpleHttpRequest;
import org.keycloak.scim.protocol.response.ErrorResponse;
import org.keycloak.scim.protocol.response.ListResponse;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.apache.http.HttpStatus;
import static java.util.Objects.requireNonNull;
public abstract class AbstractScimResourceClient<R extends ResourceTypeRepresentation> implements AutoCloseable {
private final ScimClient client;
private final Class<R> resourceTypeClass;
public AbstractScimResourceClient(ScimClient client, Class<R> resourceType) {
this.client = client;
this.resourceTypeClass = resourceType;
}
public R create(R resource) {
requireNonNull(resource, "SCIM resource must not be null");
return client.execute(client.doPost(resourceTypeClass).json(resource), resourceTypeClass);
}
public R update(R resource) {
requireNonNull(resource, "SCIM resource must not be null");
return client.execute(client.doPut(resourceTypeClass, resource.getId())
.json(resource), resourceTypeClass);
}
public void delete(String id) {
requireNonNull(id, "SCIM resource ID must not be null");
client.execute(client.doDelete(resourceTypeClass, id));
}
public R get(String id) {
requireNonNull(id, "SCIM resource ID must not be null");
try {
return client.execute(doGet("/" + id), resourceTypeClass);
} catch (ScimClientException scime) {
ErrorResponse error = scime.getError();
if (error != null) {
if (HttpStatus.SC_NOT_FOUND == error.getStatusInt()) {
return null;
}
}
throw scime;
}
}
protected ListResponse<R> doFilter(ResourceFilter filter) {
SimpleHttpRequest request = doGet("");
String query = filter.build();
if (!query.isEmpty()) {
request = request.param("filter", query);
}
return client.execute(request, ListResponse.class);
}
protected SimpleHttpRequest doGet(String path) {
return client.doGet(resourceTypeClass, path);
}
@Override
public void close() throws Exception {
client.close();
}
}
@@ -0,0 +1,19 @@
package org.keycloak.scim.client;
public class ResourceFilter {
public static ResourceFilter filter() {
return new ResourceFilter();
}
private StringBuilder filter = new StringBuilder();
public ResourceFilter eq(String property, String value) {
filter.append(property).append(" ").append("eq").append(" ").append("\"").append(value).append("\"");
return this;
}
public String build() {
return filter.toString();
}
}
@@ -0,0 +1,208 @@
package org.keycloak.scim.client;
import java.io.IOException;
import jakarta.ws.rs.core.Response.Status;
import org.keycloak.http.simple.SimpleHttp;
import org.keycloak.http.simple.SimpleHttpRequest;
import org.keycloak.http.simple.SimpleHttpResponse;
import org.keycloak.scim.client.authorization.AuthorizationMethod;
import org.keycloak.scim.protocol.response.ErrorResponse;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.config.ServiceProviderConfig;
import org.apache.http.HttpHeaders;
import org.apache.http.client.HttpClient;
/**
* <p>A client for interacting with a SCIM 2.0 compliant server. This client provides methods for performing
* CRUD operations on SCIM resources such as users and groups, as well as retrieving the service provider configuration.
*
* <p>This client is not designed as a standalone SCIM client library, but rather for internal use within Keycloak for testing and integration purposes.
* It is built on top of {@link SimpleHttp} client.
*
* <p>In order to create instances of this client you will need a {@link HttpClient} instance. At runtime, this instance
* is available from the {@link org.keycloak.connections.httpclient.HttpClientProvider} provider.
*
* <p>Example usage:
* <pre>
* try (ScimClient scimClient = ScimClient.create(httpClient)
* .withBaseUrl("https://scim.example.com")
* .withAuthorization(new ScimClient.Builder.OAuth2Bearer("https://auth.examplecom/realms/master/protocol/openid-connect/token", "client-id", "client-secret"))
* .build()) {
* // Use scimClient to perform operations, e.g.:
* ScimUser user = scimClient.users().get("user-id");
* }
* </pre>
*/
public final class ScimClient implements AutoCloseable {
private static final String APPLICATION_SCIM_JSON = "application/scim+json";
private final SimpleHttp http;
private String baseUrl;
private AuthorizationMethod authorizationMethod;
private ScimClient(HttpClient http) {
this.http = SimpleHttp.create(http);
}
public static Builder create(HttpClient httpClient) {
return new Builder(httpClient);
}
public ScimUsersClient users() {
return new ScimUsersClient(this);
}
public ScimGroupsClient groups() {
return new ScimGroupsClient(this);
}
public ScimConfigClient config() {
return new ScimConfigClient(this);
}
public ScimResourceTypesClient resourceTypes() {
return new ScimResourceTypesClient(this);
}
@Override
public void close() {
// no-op for now
}
SimpleHttpResponse execute(SimpleHttpRequest request) throws ScimClientException {
try {
SimpleHttpResponse response = request.asResponse();
if (!Status.Family.familyOf(response.getStatus()).equals(Status.Family.SUCCESSFUL)) {
String payload = response.asString();
try (response) {
ErrorResponse error = response.asJson(ErrorResponse.class);
throw new ScimClientException("Error response from SCIM server", error);
} catch (ScimClientException sce) {
throw sce;
} catch (Exception e) {
throw new ScimClientException("Unexpected error response from SCIM server", new ErrorResponse(payload, response.getStatus()));
}
}
return response;
} catch (ScimClientException sce) {
throw sce;
} catch (Exception e) {
throw new ScimClientException("Unexpected response from SCIM server", e);
}
}
SimpleHttpRequest doGet(Class<? extends ResourceTypeRepresentation> resourceType, String path) {
return beforeRequest(http.doGet(baseUrl + getResourceTypePath(resourceType) + path))
.header(HttpHeaders.ACCEPT, APPLICATION_SCIM_JSON);
}
SimpleHttpRequest doGet(Class<? extends ResourceTypeRepresentation> resourceType) {
return beforeRequest(http.doGet(baseUrl + getResourceTypePath(resourceType)))
.header(HttpHeaders.ACCEPT, APPLICATION_SCIM_JSON);
}
SimpleHttpRequest doPost(Class<? extends ResourceTypeRepresentation> resourceType) {
return beforeRequest(http.doPost(baseUrl + getResourceTypePath(resourceType)))
.header(HttpHeaders.CONTENT_TYPE, APPLICATION_SCIM_JSON);
}
private String getResourceTypePath(Class<? extends ResourceTypeRepresentation> resourceType) {
String path = "/" + resourceType.getSimpleName();
if (resourceType.equals(ServiceProviderConfig.class)) {
return path;
}
return path + "s";
}
SimpleHttpRequest doDelete(Class<? extends ResourceTypeRepresentation> resourceType, String id) {
return beforeRequest(http.doDelete(baseUrl + getResourceTypePath(resourceType) + "/" + id));
}
SimpleHttpRequest doPut(Class<? extends ResourceTypeRepresentation> resourceType, String id) {
return beforeRequest(http.doPut(baseUrl + getResourceTypePath(resourceType) + "/" + id))
.header(HttpHeaders.CONTENT_TYPE, APPLICATION_SCIM_JSON);
}
<T> T execute(SimpleHttpRequest request, Class<T> responseType) {
try (SimpleHttpResponse response = execute(request)) {
if (responseType == null) {
return null;
}
return response.asJson(responseType);
} catch (IOException e) {
throw new ScimClientException("Error executing request", e);
}
}
private SimpleHttpRequest beforeRequest(SimpleHttpRequest request) {
authorizationMethod.onBefore(http, request);
return request;
}
private void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public static class Builder {
private static final String DEFAULT_API_PATH = "/scim/v2/";
private final ScimClient client;
private Builder(HttpClient baseUrl) {
client = new ScimClient(baseUrl);
}
/**
* The base URL of the SCIM server, e.g. "https://scim.example.com". The default API path "/scim/v2/" will be appended to this base URL.
*
* @param baseUrl the base URL of the SCIM server
* @return this builder for chaining
*/
public Builder withBaseUrl(String baseUrl) {
client.setBaseUrl(baseUrl + DEFAULT_API_PATH);
return this;
}
/**
* Configure the authorization method to use for requests. This method will be called before each request is sent,
* allowing you to set the appropriate headers for authentication.
*
* @param method the authorization method to use for requests
* @return this builder for chaining
*/
public Builder withAuthorization(AuthorizationMethod method) {
client.setAuthorizationMethod(method);
return this;
}
/**
* Builds the {@link ScimClient} instance with the configured settings. The client will be connected and ready to use after this method is called.
*
* @return the connected {@link ScimClient} instance
*/
public ScimClient build() {
return client.connect();
}
}
private void setAuthorizationMethod(AuthorizationMethod method) {
this.authorizationMethod = method;
}
private ScimClient connect() {
return this;
}
}
@@ -0,0 +1,35 @@
package org.keycloak.scim.client;
import org.keycloak.scim.protocol.response.ErrorResponse;
public class ScimClientException extends RuntimeException {
private final String response;
private ErrorResponse error;
public ScimClientException(String message, Throwable cause, String response) {
super(message, cause);
this.response = response;
}
public ScimClientException(String message, Throwable cause) {
this(message, cause, null);
}
public ScimClientException(String message, String response) {
this(message, null, response);
}
public <T> ScimClientException(String message, ErrorResponse error) {
this(message, null, null);
this.error = error;
}
public String getResponse() {
return response;
}
public ErrorResponse getError() {
return error;
}
}
@@ -0,0 +1,17 @@
package org.keycloak.scim.client;
import org.keycloak.scim.resource.config.ServiceProviderConfig;
public class ScimConfigClient {
private final ScimClient client;
public ScimConfigClient(ScimClient client) {
this.client = client;
}
public ServiceProviderConfig get() {
return client.execute(client.doGet(ServiceProviderConfig.class), ServiceProviderConfig.class);
}
}
@@ -0,0 +1,15 @@
package org.keycloak.scim.client;
import org.keycloak.scim.resource.group.Group;
public class ScimGroupsClient extends AbstractScimResourceClient<Group> {
public ScimGroupsClient(ScimClient client) {
super(client, Group.class);
}
@Override
public void close() {
}
}
@@ -0,0 +1,18 @@
package org.keycloak.scim.client;
import org.keycloak.scim.protocol.response.ListResponse;
import org.keycloak.scim.resource.resourcetype.ResourceType;
public class ScimResourceTypesClient {
private final ScimClient client;
public ScimResourceTypesClient(ScimClient client) {
this.client = client;
}
@SuppressWarnings("unchecked")
public ListResponse<ResourceType> getAll() {
return client.execute(client.doGet(ResourceType.class), ListResponse.class);
}
}
@@ -0,0 +1,39 @@
package org.keycloak.scim.client;
import java.util.List;
import org.keycloak.scim.protocol.response.ListResponse;
import org.keycloak.scim.resource.user.User;
import static java.util.Objects.requireNonNull;
import static org.keycloak.scim.client.ResourceFilter.filter;
public class ScimUsersClient extends AbstractScimResourceClient<User> {
public ScimUsersClient(ScimClient client) {
super(client, User.class);
}
public User getByUsername(String userName) {
requireNonNull(userName, "userName must not be null");
ListResponse<User> r = doFilter(filter().eq("userName", userName));
List<User> resources = r.getResources();
if (resources.isEmpty()) {
return null;
}
if (resources.size() > 1) {
throw new IllegalStateException("More than one user with username " + userName + " found");
}
return resources.get(0);
}
@Override
public void close() throws Exception {
}
}
@@ -0,0 +1,9 @@
package org.keycloak.scim.client.authorization;
import org.keycloak.http.simple.SimpleHttp;
import org.keycloak.http.simple.SimpleHttpRequest;
public interface AuthorizationMethod {
void onBefore(SimpleHttp http, SimpleHttpRequest request);
}
@@ -0,0 +1,38 @@
package org.keycloak.scim.client.authorization;
import java.io.IOException;
import org.keycloak.OAuth2Constants;
import org.keycloak.http.simple.SimpleHttp;
import org.keycloak.http.simple.SimpleHttpRequest;
import org.keycloak.representations.AccessTokenResponse;
import org.apache.http.HttpHeaders;
/**
* An implementation of {@link AuthorizationMethod} that obtains an access token using the
* OAuth 2.0 Client Credentials grant type and sets the appropriate Authorization header for requests.
*
* @param tokenEndpoint the token endpoint URL to obtain the access token from
* @param clientId the client ID
* @param clientSecret the client secret
*/
public record OAuth2Bearer(String tokenEndpoint, String clientId, String clientSecret) implements AuthorizationMethod {
@Override
public void onBefore(SimpleHttp http, SimpleHttpRequest request) {
try {
AccessTokenResponse response = http.doPost(tokenEndpoint)
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS)
.param(OAuth2Constants.CLIENT_ID, clientId)
.param(OAuth2Constants.CLIENT_SECRET, clientSecret)
.asJson(AccessTokenResponse.class);
String token = response.getToken();
request.header(HttpHeaders.AUTHORIZATION, "Bearer " + token);
} catch (IOException e) {
throw new RuntimeException("Failed to obtain access token", e);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-parent</artifactId>
<groupId>org.keycloak</groupId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-scim-core</artifactId>
<name>Keycloak SCIM Core</name>
<description>
This module provides an API around the SCIM Core Schema(RFC7643)
</description>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,8 @@
package org.keycloak.scim.protocol;
/**
* Thrown to indicate that access to a protected resource is forbidden.
*/
public class ForbiddenException extends RuntimeException{
}
@@ -0,0 +1,74 @@
package org.keycloak.scim.protocol.request;
import java.util.List;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PatchRequest {
public static final String SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
@JsonProperty("schemas")
private Set<String> schemas = Set.of(SCHEMA);
@JsonProperty("Operations")
private List<PatchOperation> operations;
public Set<String> getSchemas() {
return schemas;
}
public void setSchemas(Set<String> schemas) {
this.schemas = schemas;
}
public List<PatchOperation> getOperations() {
return operations;
}
public void setOperations(List<PatchOperation> operations) {
this.operations = operations;
}
/**
* Represents a single PATCH operation
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class PatchOperation {
@JsonProperty("op")
private String op; // "add", "remove", "replace"
@JsonProperty("path")
private String path;
@JsonProperty("value")
private Object value;
public String getOp() {
return op;
}
public void setOp(String op) {
this.op = op;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public Object getValue() {
return value;
}
public void setValue(Object value) {
this.value = value;
}
}
}
@@ -0,0 +1,71 @@
package org.keycloak.scim.protocol.response;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
public static final String SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
@JsonProperty("schemas")
private Set<String> schemas = Set.of(SCHEMA);
@JsonProperty("status")
private String status;
@JsonProperty("scimType")
private String scimType;
@JsonProperty("detail")
private String detail;
public ErrorResponse() {
// for reflection
}
public ErrorResponse(String detail, int status) {
this.detail = detail;
this.status = Integer.toString(status);
}
public Set<String> getSchemas() {
return schemas;
}
public void setSchemas(Set<String> schemas) {
this.schemas = schemas;
}
public String getStatus() {
return status;
}
@JsonIgnore
public int getStatusInt() {
return status == null ? -1 : Integer.parseInt(status);
}
public void setStatus(String status) {
this.status = status;
}
public String getScimType() {
return scimType;
}
public void setScimType(String scimType) {
this.scimType = scimType;
}
public String getDetail() {
return detail;
}
public void setDetail(String detail) {
this.detail = detail;
}
}
@@ -0,0 +1,80 @@
package org.keycloak.scim.protocol.response;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ListResponse<T extends ResourceTypeRepresentation> {
public static final String SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
@JsonProperty("schemas")
private Set<String> schemas = Set.of(SCHEMA);
@JsonProperty("totalResults")
private Integer totalResults;
@JsonProperty("Resources")
@JsonDeserialize(using = ListResponseDeserializer.class)
private List<T> resources;
@JsonProperty("startIndex")
private Integer startIndex;
@JsonProperty("itemsPerPage")
private Integer itemsPerPage;
public Set<String> getSchemas() {
return schemas;
}
public void setSchemas(Set<String> schemas) {
this.schemas = schemas;
}
public Integer getTotalResults() {
return totalResults;
}
public void setTotalResults(Integer totalResults) {
this.totalResults = totalResults;
}
public List<T> getResources() {
return resources;
}
public void setResources(List<T> resources) {
this.resources = resources;
}
public Integer getStartIndex() {
return startIndex;
}
public void setStartIndex(Integer startIndex) {
this.startIndex = startIndex;
}
public Integer getItemsPerPage() {
return itemsPerPage;
}
public void setItemsPerPage(Integer itemsPerPage) {
this.itemsPerPage = itemsPerPage;
}
public void addResource(T resource) {
if (resources == null) {
resources = new ArrayList<>();
}
resources.add(resource);
}
}
@@ -0,0 +1,76 @@
package org.keycloak.scim.protocol.response;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.group.Group;
import org.keycloak.scim.resource.resourcetype.ResourceType;
import org.keycloak.scim.resource.user.User;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import static org.keycloak.scim.resource.Scim.getCoreSchema;
public class ListResponseDeserializer extends JsonDeserializer<List<ResourceTypeRepresentation>> {
@Override
public List<ResourceTypeRepresentation> deserialize(JsonParser parser, DeserializationContext context) throws IOException {
ObjectMapper mapper = (ObjectMapper) parser.getCodec();
JsonNode nodes = mapper.readTree(parser);
List<ResourceTypeRepresentation> resources = new ArrayList<>();
if (nodes.isArray()) {
for (JsonNode node : nodes) {
ResourceTypeRepresentation resource = parseNode(mapper, node);
if (resource != null) {
resources.add(resource);
}
}
}
return resources;
}
private ResourceTypeRepresentation parseNode(ObjectMapper mapper, JsonNode node) throws IOException {
Class<? extends ResourceTypeRepresentation> resourceType = getResourceType(node);
return mapper.treeToValue(node, resourceType);
}
private Class<? extends ResourceTypeRepresentation> getResourceType(JsonNode node) {
Set<String> schemas = getSchemas(node);
if (schemas.contains(getCoreSchema(User.class))) {
return User.class;
} else if (schemas.contains(getCoreSchema(Group.class))) {
return Group.class;
} else if (schemas.contains(getCoreSchema(ResourceType.class))) {
return ResourceType.class;
}
throw new IllegalArgumentException("Could not map resource type from any of the schemas: " + schemas);
}
private Set<String> getSchemas(JsonNode node) {
if (node.has("schemas")) {
JsonNode schemasNode = node.get("schemas");
if (schemasNode.isArray()) {
return schemasNode.valueStream().map(JsonNode::asText).collect(Collectors.toSet());
}
return Set.of(schemasNode.asText());
}
throw new IllegalArgumentException("No schema set to JSON node");
}
}
@@ -0,0 +1,88 @@
package org.keycloak.scim.resource;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import org.keycloak.scim.resource.common.Meta;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import static org.keycloak.scim.resource.Scim.getCoreSchema;
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties({"type"})
public abstract class ResourceTypeRepresentation {
@JsonProperty("schemas")
private Set<String> schemas;
@JsonProperty("id")
private String id;
@JsonProperty("externalId")
private String externalId;
@JsonProperty("meta")
private Meta meta;
@JsonIgnore
private long createdTimestamp;
public Set<String> getSchemas() {
if (schemas == null) {
schemas = new HashSet<>();
schemas.add(getCoreSchema(this.getClass()));
}
return schemas;
}
public void setSchemas(Set<String> schemas) {
this.schemas = schemas;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getExternalId() {
return externalId;
}
public void setExternalId(String externalId) {
this.externalId = externalId;
}
public Meta getMeta() {
return meta;
}
public void setMeta(Meta meta) {
this.meta = meta;
}
public boolean hasSchema(String schema) {
return Optional.ofNullable(getSchemas()).orElse(Set.of()).contains(schema);
}
public void setCreatedTimestamp(long createdTimestamp) {
this.createdTimestamp = createdTimestamp;
}
public long getCreatedTimestamp() {
return createdTimestamp;
}
public void addSchema(String schema) {
if (schemas == null) {
schemas = new HashSet<>();
}
schemas.add(schema);
}
}
@@ -0,0 +1,30 @@
package org.keycloak.scim.resource;
public final class Scim {
// Core Schemas
public static final String ENTERPRISE_USER_SCHEMA = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
public static final String USER_CORE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
public static final String GROUP_CORE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group";
public static final String SERVICE_PROVIDER_CONFIG_CORE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
public static final String SCHEMA_CORE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Schema";
public static final String RESOURCE_TYPE_CORE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType";
// Core Resource Types
public static final String USER_RESOURCE_TYPE = "User";
public static final String GROUP_RESOURCE_TYPE = "Group";
public static final String SERVICE_PROVIDER_CONFIG_RESOURCE_TYPE = "ServiceProviderConfig";
public static final String RESOURCE_TYPE_RESOURCE_TYPE = "ResourceType";
public static final String SCHEMA_RESOURCE_TYPE = "Schema";
public static String getCoreSchema(Class<? extends ResourceTypeRepresentation> resourceType) {
return switch (resourceType.getSimpleName()) {
case USER_RESOURCE_TYPE -> USER_CORE_SCHEMA;
case GROUP_RESOURCE_TYPE -> GROUP_CORE_SCHEMA;
case SERVICE_PROVIDER_CONFIG_RESOURCE_TYPE -> SERVICE_PROVIDER_CONFIG_CORE_SCHEMA;
case RESOURCE_TYPE_RESOURCE_TYPE -> RESOURCE_TYPE_CORE_SCHEMA;
case SCHEMA_RESOURCE_TYPE -> SCHEMA_CORE_SCHEMA;
default -> throw new IllegalArgumentException("Unknown resource type " + resourceType);
};
}
}
@@ -0,0 +1,96 @@
package org.keycloak.scim.resource.common;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Address {
@JsonProperty("formatted")
private String formatted;
@JsonProperty("streetAddress")
private String streetAddress;
@JsonProperty("locality")
private String locality;
@JsonProperty("region")
private String region;
@JsonProperty("postalCode")
private String postalCode;
@JsonProperty("country")
private String country;
@JsonProperty("type")
private String type;
@JsonProperty("primary")
private Boolean primary;
public String getFormatted() {
return formatted;
}
public void setFormatted(String formatted) {
this.formatted = formatted;
}
public String getStreetAddress() {
return streetAddress;
}
public void setStreetAddress(String streetAddress) {
this.streetAddress = streetAddress;
}
public String getLocality() {
return locality;
}
public void setLocality(String locality) {
this.locality = locality;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public String getPostalCode() {
return postalCode;
}
public void setPostalCode(String postalCode) {
this.postalCode = postalCode;
}
public String getCountry() {
return country;
}
public void setCountry(String country) {
this.country = country;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public Boolean getPrimary() {
return primary;
}
public void setPrimary(Boolean primary) {
this.primary = primary;
}
}
@@ -0,0 +1,22 @@
package org.keycloak.scim.resource.common;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Email extends MultiValuedAttribute {
public Email() {
}
public Email(String email) {
setValue(email);
setPrimary(true);
setType("other");
}
public Email(String value, String type, Boolean primary) {
setValue(value);
setType(type);
setPrimary(primary);
}
}
@@ -0,0 +1,16 @@
package org.keycloak.scim.resource.common;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class InstantMessagingAddress extends MultiValuedAttribute {
public InstantMessagingAddress() {
}
public InstantMessagingAddress(String value, String type, Boolean primary) {
setValue(value);
setType(type);
setPrimary(primary);
}
}
@@ -0,0 +1,63 @@
package org.keycloak.scim.resource.common;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Meta {
@JsonProperty("resourceType")
private String resourceType;
@JsonProperty("created")
private String created;
@JsonProperty("lastModified")
private String lastModified;
@JsonProperty("location")
private String location;
@JsonProperty("version")
private String version;
public String getResourceType() {
return resourceType;
}
public void setResourceType(String resourceType) {
this.resourceType = resourceType;
}
public String getCreated() {
return created;
}
public void setCreated(String created) {
this.created = created;
}
public String getLastModified() {
return lastModified;
}
public void setLastModified(String lastModified) {
this.lastModified = lastModified;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
public String getVersion() {
return version;
}
public void setVersion(String version) {
this.version = version;
}
}
@@ -0,0 +1,63 @@
package org.keycloak.scim.resource.common;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MultiValuedAttribute {
@JsonProperty("value")
private String value;
@JsonProperty("display")
private String display;
@JsonProperty("type")
private String type;
@JsonProperty("primary")
private Boolean primary;
@JsonProperty("$ref")
private String ref;
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
public String getDisplay() {
return display;
}
public void setDisplay(String display) {
this.display = display;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public Boolean getPrimary() {
return primary;
}
public void setPrimary(Boolean primary) {
this.primary = primary;
}
public String getRef() {
return ref;
}
public void setRef(String ref) {
this.ref = ref;
}
}
@@ -0,0 +1,90 @@
package org.keycloak.scim.resource.common;
import java.util.Optional;
import org.keycloak.utils.StringUtil;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Name {
@JsonProperty("formatted")
private String formatted;
@JsonProperty("familyName")
private String familyName;
@JsonProperty("givenName")
private String givenName;
@JsonProperty("middleName")
private String middleName;
@JsonProperty("honorificPrefix")
private String honorificPrefix;
@JsonProperty("honorificSuffix")
private String honorificSuffix;
public String getFormatted() {
if (formatted == null) {
formatted = Optional.ofNullable(honorificPrefix).orElse("") +
" " +
Optional.ofNullable(givenName).orElse("") +
" " +
Optional.ofNullable(middleName).orElse("") +
" " +
Optional.ofNullable(familyName).orElse("") +
" " +
Optional.ofNullable(honorificSuffix).orElse("");
}
return StringUtil.isBlank(formatted.trim()) ? null : formatted;
}
public void setFormatted(String formatted) {
this.formatted = formatted;
}
public String getFamilyName() {
return familyName;
}
public void setFamilyName(String familyName) {
this.familyName = familyName;
}
public String getGivenName() {
return givenName;
}
public void setGivenName(String givenName) {
this.givenName = givenName;
}
public String getMiddleName() {
return middleName;
}
public void setMiddleName(String middleName) {
this.middleName = middleName;
}
public String getHonorificPrefix() {
return honorificPrefix;
}
public void setHonorificPrefix(String honorificPrefix) {
this.honorificPrefix = honorificPrefix;
}
public String getHonorificSuffix() {
return honorificSuffix;
}
public void setHonorificSuffix(String honorificSuffix) {
this.honorificSuffix = honorificSuffix;
}
}
@@ -0,0 +1,16 @@
package org.keycloak.scim.resource.common;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PhoneNumber extends MultiValuedAttribute {
public PhoneNumber() {
}
public PhoneNumber(String value, String type, Boolean primary) {
setValue(value);
setType(type);
setPrimary(primary);
}
}
@@ -0,0 +1,16 @@
package org.keycloak.scim.resource.common;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Photo extends MultiValuedAttribute {
public Photo() {
}
public Photo(String value, String type, Boolean primary) {
setValue(value);
setType(type);
setPrimary(primary);
}
}
@@ -0,0 +1,16 @@
package org.keycloak.scim.resource.common;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class X509Certificate extends MultiValuedAttribute {
public X509Certificate() {
}
public X509Certificate(String value, String type, Boolean primary) {
setValue(value);
setType(type);
setPrimary(primary);
}
}
@@ -0,0 +1,242 @@
package org.keycloak.scim.resource.config;
import java.util.List;
import java.util.Set;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
public class ServiceProviderConfig extends ResourceTypeRepresentation {
public static final String SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
@JsonProperty("documentationUri")
private String documentationUri;
@JsonProperty("patch")
private Supported patch;
@JsonProperty("bulk")
private BulkSupport bulk;
@JsonProperty("filter")
private FilterSupport filter;
@JsonProperty("changePassword")
private Supported changePassword;
@JsonProperty("sort")
private Supported sort;
@JsonProperty("etag")
private Supported etag;
@JsonProperty("authenticationSchemes")
private List<AuthenticationScheme> authenticationSchemes;
@Override
public Set<String> getSchemas() {
return Set.of(SCHEMA);
}
public String getDocumentationUri() {
return documentationUri;
}
public void setDocumentationUri(String documentationUri) {
this.documentationUri = documentationUri;
}
public Supported getPatch() {
return patch;
}
public void setPatch(Supported patch) {
this.patch = patch;
}
public BulkSupport getBulk() {
return bulk;
}
public void setBulk(BulkSupport bulk) {
this.bulk = bulk;
}
public FilterSupport getFilter() {
return filter;
}
public void setFilter(FilterSupport filter) {
this.filter = filter;
}
public Supported getChangePassword() {
return changePassword;
}
public void setChangePassword(Supported changePassword) {
this.changePassword = changePassword;
}
public Supported getSort() {
return sort;
}
public void setSort(Supported sort) {
this.sort = sort;
}
public Supported getEtag() {
return etag;
}
public void setEtag(Supported etag) {
this.etag = etag;
}
public List<AuthenticationScheme> getAuthenticationSchemes() {
return authenticationSchemes;
}
public void setAuthenticationSchemes(List<AuthenticationScheme> authenticationSchemes) {
this.authenticationSchemes = authenticationSchemes;
}
/**
* Generic supported feature indicator
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Supported {
public static final Supported TRUE = new Supported(true);
public static final Supported FALSE = new Supported(false);
@JsonProperty("supported")
private Boolean supported;
public Supported(boolean supported) {
this.supported = supported;
}
public Supported() {
this.supported = false;
}
public Boolean getSupported() {
return supported;
}
public void setSupported(Boolean supported) {
this.supported = supported;
}
}
/**
* Bulk operation support configuration
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class BulkSupport extends Supported {
@JsonProperty("maxOperations")
private Integer maxOperations = 0;
@JsonProperty("maxPayloadSize")
private Integer maxPayloadSize = 0;
public Integer getMaxOperations() {
return maxOperations;
}
public void setMaxOperations(Integer maxOperations) {
this.maxOperations = maxOperations;
}
public Integer getMaxPayloadSize() {
return maxPayloadSize;
}
public void setMaxPayloadSize(Integer maxPayloadSize) {
this.maxPayloadSize = maxPayloadSize;
}
}
/**
* Filter support configuration
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class FilterSupport extends Supported {
@JsonProperty("maxResults")
private Integer maxResults = 0;
public Integer getMaxResults() {
return maxResults;
}
public void setMaxResults(Integer maxResults) {
this.maxResults = maxResults;
}
}
/**
* Authentication scheme details
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class AuthenticationScheme {
@JsonProperty("type")
private String type;
@JsonProperty("name")
private String name;
@JsonProperty("description")
private String description;
@JsonProperty("specUri")
private String specUri;
@JsonProperty("documentationUri")
private String documentationUri;
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getSpecUri() {
return specUri;
}
public void setSpecUri(String specUri) {
this.specUri = specUri;
}
public String getDocumentationUri() {
return documentationUri;
}
public void setDocumentationUri(String documentationUri) {
this.documentationUri = documentationUri;
}
}
}
@@ -0,0 +1,40 @@
package org.keycloak.scim.resource.group;
import java.util.List;
import java.util.Set;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import com.fasterxml.jackson.annotation.JsonProperty;
public class Group extends ResourceTypeRepresentation {
public static final String SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group";
@JsonProperty("displayName")
private String displayName;
@JsonProperty("members")
private List<Member> members;
@Override
public Set<String> getSchemas() {
return Set.of(SCHEMA);
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public List<Member> getMembers() {
return members;
}
public void setMembers(List<Member> members) {
this.members = members;
}
}
@@ -0,0 +1,52 @@
package org.keycloak.scim.resource.group;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Member {
@JsonProperty("value")
private String value;
@JsonProperty("$ref")
private String ref;
@JsonProperty("type")
private String type;
@JsonProperty("display")
private String display;
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
public String getRef() {
return ref;
}
public void setRef(String ref) {
this.ref = ref;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getDisplay() {
return display;
}
public void setDisplay(String display) {
this.display = display;
}
}
@@ -0,0 +1,100 @@
package org.keycloak.scim.resource.resourcetype;
import java.util.List;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
public class ResourceType extends org.keycloak.scim.resource.ResourceTypeRepresentation {
public static final String SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType";
@JsonProperty("name")
private String name;
@JsonProperty("description")
private String description;
@JsonProperty("endpoint")
private String endpoint;
@JsonProperty("schema")
private String schema;
@JsonProperty("schemaExtensions")
private List<SchemaExtension> schemaExtensions;
@Override
public Set<String> getSchemas() {
return Set.of(SCHEMA);
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getEndpoint() {
return endpoint;
}
public void setEndpoint(String endpoint) {
this.endpoint = endpoint;
}
public String getSchema() {
return schema;
}
public void setSchema(String schema) {
this.schema = schema;
}
public List<SchemaExtension> getSchemaExtensions() {
return schemaExtensions;
}
public void setSchemaExtensions(List<SchemaExtension> schemaExtensions) {
this.schemaExtensions = schemaExtensions;
}
/**
* Represents a schema extension for a resource type
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class SchemaExtension {
@JsonProperty("schema")
private String schema;
@JsonProperty("required")
private Boolean required = false;
public String getSchema() {
return schema;
}
public void setSchema(String schema) {
this.schema = schema;
}
public Boolean getRequired() {
return required;
}
public void setRequired(Boolean required) {
this.required = required;
}
}
}
@@ -0,0 +1,168 @@
package org.keycloak.scim.resource.schema;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.keycloak.models.Model;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import org.keycloak.scim.resource.schema.attribute.AttributeMapper;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import static org.keycloak.scim.resource.Scim.getCoreSchema;
import static org.keycloak.utils.JsonUtils.getJsonValue;
public abstract class AbstractModelSchema<M extends Model, R extends ResourceTypeRepresentation> implements ModelSchema<M, R> {
private final String name;
private final Map<String, Attribute<M, R>> attributes;
protected AbstractModelSchema(String name, List<Attribute<M, R>> attributes) {
this.name = name;
this.attributes = attributes.stream().collect(Collectors.toMap(Attribute::getName, Function.identity()));
}
@Override
public String getName() {
return name;
}
@Override
public Map<String, Attribute<M, R>> getAttributes() {
return attributes;
}
@Override
public void populate(M model, R representation) {
populateModel(model, representation);
representation.setId(model.getId());
}
@Override
public void populate(R resource, M model) {
populateResourceType(resource, model);
resource.setId(model.getId());
}
@Override
public void validate(R representation) throws SchemaValidationException {
// validate here the schema
}
/**
* Returns the names of the attributes from the given {@code model}.
*
* @param model the model to get the attribute names from
* @return the names of the attributes defined in the model
*/
protected abstract Set<String> getAttributeNames(M model);
/**
* Returns the value of the attribute with the given {@code name} from the given {@code model}.
*
* @param model the model to get the attribute value from
* @param name the name of the attribute to get the value from
* @return the value of the attribute with the given name from the model
*/
protected abstract String getAttributeValue(M model, String name);
/**
* Returns the name of the attribute in the schema for the attribute with the given {@code name} from the given {@code model}.
*
* @param model the model to get the attribute schema name from
* @param name the name of the attribute to get the schema name from
* @return the name of the attribute in the schema for the attribute with the given name from the model
*/
protected abstract String getAttributeSchemaName(M model, String name);
/**
* Returns the name of the schema for the attribute with the given {@code name} from the given {@code model}.
* It is used to determine which schema the attribute belongs to.
*
* @param model the model to get the attribute schema from
* @param name the name of the attribute to get the schema from
* @return the name of the schema for the attribute with the given name from the model
*/
protected abstract String getAttributeSchema(M model, String name);
private void populateModel(M model, R resource) {
ObjectNode objectNode;
try {
objectNode = JsonSerialization.createObjectNode(resource);
} catch (Exception e) {
throw new RuntimeException("Failed to convert representation to JSON", e);
}
for (String name : getAttributeNames(model)) {
Attribute<M, R> attribute = getAttributeMapper(model, resource, name);
if (attribute == null) {
continue;
}
AttributeMapper<M, R> mapper = attribute.getMapper();
if (mapper == null) {
continue;
}
Object value = getJsonValue(objectNode, attribute.getName());
if (value == null) {
JsonNode schemaExtension = objectNode.get(getName());
value = getJsonValue(schemaExtension, attribute.getName());
}
if (value != null) {
mapper.setValue(model, name, value.toString());
}
}
}
private void populateResourceType(R resource, M model) {
for (String name : getAttributeNames(model)) {
Attribute<M, R> attribute = getAttributeMapper(model, resource, name);
if (attribute == null) {
continue;
}
AttributeMapper<M, R> mapper = attribute.getMapper();
if (mapper == null) {
continue;
}
String value = getAttributeValue(model, name);
mapper.setValue(resource, value);
resource.addSchema(this.name);
}
}
private Attribute<M, R> getAttributeMapper(M model, R resource, String name) {
Object schema = getAttributeSchema(model, name);
if (schema == null) {
schema = getCoreSchema(resource.getClass());
}
if (!this.name.equals(schema)) {
return null;
}
Object scimName = getAttributeSchemaName(model, name);
if (scimName == null) {
return null;
}
return attributes.get(scimName.toString());
}
}
@@ -0,0 +1,56 @@
package org.keycloak.scim.resource.schema;
import java.util.Map;
import org.keycloak.models.Model;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.schema.attribute.Attribute;
/**
* <p>An interface that represents a schema for a resource type.
*
* <p>A schema is a set of metadata that basically describes the attributes of a resource type. It is used to validate
* the representation of a resource type and to validate and map the attributes from the schema from a {@link ResourceTypeRepresentation}, usually an
* object from the RESTful layer, to a {@link Model} and vice versa.
*/
public interface ModelSchema<M extends Model, R extends ResourceTypeRepresentation> {
/**
* The name of the schema. It is used to identify the schema and to associate it with a resource type.
*
* @return the name of the schema
*/
String getName();
/**
* Returns the attributes defined by this schema. The key of the map is the name of the attribute and the value is
* the {@link Attribute} that describes the attribute.
*
* @return the attributes of the schema
*/
Map<String, Attribute<M, R>> getAttributes();
/**
* Populates the given {@code model} with the attributes from the given {@code representation}.
*
* @param model the model to be populated
* @param representation the representation to populate from
*/
void populate(M model, R representation);
/**
* Populates the given {@code representation} with the attributes from the given {@code model}.
*
* @param model the model to be populated
* @param representation the representation to populate from
*/
void populate(R representation, M model);
/**
* Validates the given {@code representation} against the schema. It should throw an exception if the representation is not valid.
*
* @param representation the representation to be validated
* @throws SchemaValidationException if the representation is not valid against the schema
*/
void validate(R representation) throws SchemaValidationException;
}
@@ -0,0 +1,190 @@
package org.keycloak.scim.resource.schema;
import java.util.List;
import java.util.Set;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
public class Schema extends ResourceTypeRepresentation {
public static final String SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Schema";
@JsonProperty("name")
private String name;
@JsonProperty("description")
private String description;
@JsonProperty("attributes")
private List<Attribute> attributes;
@Override
public Set<String> getSchemas() {
return Set.of(SCHEMA);
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public List<Attribute> getAttributes() {
return attributes;
}
public void setAttributes(List<Attribute> attributes) {
this.attributes = attributes;
}
/**
* Represents a schema attribute definition
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Attribute {
@JsonProperty("name")
private String name;
@JsonProperty("type")
private String type;
@JsonProperty("multiValued")
private Boolean multiValued;
@JsonProperty("description")
private String description;
@JsonProperty("required")
private Boolean required;
@JsonProperty("canonicalValues")
private List<String> canonicalValues;
@JsonProperty("caseExact")
private Boolean caseExact;
@JsonProperty("mutability")
private String mutability;
@JsonProperty("returned")
private String returned;
@JsonProperty("uniqueness")
private String uniqueness;
@JsonProperty("subAttributes")
private List<Attribute> subAttributes;
@JsonProperty("referenceTypes")
private List<String> referenceTypes;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public Boolean getMultiValued() {
return multiValued;
}
public void setMultiValued(Boolean multiValued) {
this.multiValued = multiValued;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Boolean getRequired() {
return required;
}
public void setRequired(Boolean required) {
this.required = required;
}
public List<String> getCanonicalValues() {
return canonicalValues;
}
public void setCanonicalValues(List<String> canonicalValues) {
this.canonicalValues = canonicalValues;
}
public Boolean getCaseExact() {
return caseExact;
}
public void setCaseExact(Boolean caseExact) {
this.caseExact = caseExact;
}
public String getMutability() {
return mutability;
}
public void setMutability(String mutability) {
this.mutability = mutability;
}
public String getReturned() {
return returned;
}
public void setReturned(String returned) {
this.returned = returned;
}
public String getUniqueness() {
return uniqueness;
}
public void setUniqueness(String uniqueness) {
this.uniqueness = uniqueness;
}
public List<Attribute> getSubAttributes() {
return subAttributes;
}
public void setSubAttributes(List<Attribute> subAttributes) {
this.subAttributes = subAttributes;
}
public List<String> getReferenceTypes() {
return referenceTypes;
}
public void setReferenceTypes(List<String> referenceTypes) {
this.referenceTypes = referenceTypes;
}
}
}
@@ -0,0 +1,10 @@
package org.keycloak.scim.resource.schema;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
/**
* Thrown to indicate errors when validating a {@link ResourceTypeRepresentation} against a {@link ModelSchema}.
*/
public class SchemaValidationException extends RuntimeException {
}
@@ -0,0 +1,39 @@
package org.keycloak.scim.resource.schema.attribute;
import org.keycloak.models.Model;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
/**
* Represents an attribute from a {@link org.keycloak.scim.resource.schema.ModelSchema}, its metadata and the mapper
* that is used to map the attribute from a {@link ResourceTypeRepresentation} to a {@link Model} and vice versa.
*
* @see org.keycloak.scim.resource.schema.ModelSchema
*/
public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
private final String name;
private final AttributeMapper<M, R> mapper;
public Attribute(String name, AttributeMapper<M, R> mapper) {
this.name = name;
this.mapper = mapper;
}
/**
* The name of the attribute from the {@link R} representation.
*
* @return the name of the attribute
*/
public String getName() {
return name;
}
/**
* The mapper that is used to map the attribute from a {@link ResourceTypeRepresentation} to a {@link Model} and vice versa.
*
* @return the mapper
*/
public AttributeMapper<M, R> getMapper() {
return mapper;
}
}
@@ -0,0 +1,45 @@
/*
* Copyright 2025 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.scim.resource.schema.attribute;
import java.util.function.BiConsumer;
import org.keycloak.common.util.TriConsumer;
import org.keycloak.models.Model;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
/**
* <p>An attribute mapper defines how to set an attribute to a {@link Model} and its corresponding {@link ResourceTypeRepresentation}.
*
* @see Attribute
*/
public class AttributeMapper<M extends Model, R extends ResourceTypeRepresentation> {
private final TriConsumer<M, String, String> modelSetter;
private final BiConsumer<R, String> representationSetter;
public AttributeMapper(TriConsumer<M, String, String> modelSetter, BiConsumer<R, String> representationSetter) {
this.modelSetter = modelSetter;
this.representationSetter = representationSetter;
}
public AttributeMapper(BiConsumer<M, String> modelSetter, BiConsumer<R, String> representationSetter) {
this((model, name, value) -> modelSetter.accept(model, value), representationSetter);
}
public void setValue(R representation, String value) {
representationSetter.accept(representation, value);
}
public void setValue(M model, String name, String value) {
modelSetter.accept(model, name, value);
}
}
@@ -0,0 +1,148 @@
package org.keycloak.scim.resource.spi;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.Model;
import org.keycloak.scim.protocol.ForbiddenException;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.schema.ModelSchema;
public abstract class AbstractScimResourceTypeProvider<M extends Model, R extends ResourceTypeRepresentation> implements ScimResourceTypeProvider<R> {
protected final KeycloakSession session;
private final ModelSchema<M, R> schema;
private final List<ModelSchema<M, R>> schemaExtensions;
private final List<ModelSchema<M, R>> schemas;
public AbstractScimResourceTypeProvider(KeycloakSession session, ModelSchema<M, R> schema, List<ModelSchema<M, R>> schemaExtensions) {
this.session = session;
this.schema = schema;
this.schemaExtensions = schemaExtensions;
this.schemas = new ArrayList<>();
this.schemas.add(schema);
this.schemas.addAll(schemaExtensions);
}
public AbstractScimResourceTypeProvider(KeycloakSession session, ModelSchema<M, R> schema) {
this(session, schema, List.of());
}
@Override
public R create(R resource) {
KeycloakContext context = session.getContext();
if (!context.hasPermission(getRealmResourceType(), AdminPermissionsSchema.MANAGE)) {
throw new ForbiddenException();
}
return onCreate(resource);
}
@Override
public R update(R resource) {
M model = getModel(resource.getId());
KeycloakContext context = session.getContext();
if (!context.hasPermission(model, getRealmResourceType(), AdminPermissionsSchema.MANAGE)) {
throw new ForbiddenException();
}
populate(model, resource);
return onUpdate(model, resource);
}
@Override
public R get(String id) {
M model = getModel(id);
if (model == null) {
return null;
}
R resource = createResourceTypeInstance();
KeycloakContext context = session.getContext();
if (!context.hasPermission(model, getRealmResourceType(), AdminPermissionsSchema.MANAGE)) {
throw new ForbiddenException();
}
for (ModelSchema<M, R> schema : schemas) {
schema.populate(resource, model);
}
return resource;
}
@Override
public Stream<R> getAll() {
return getModels().map(m -> {
if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(session.getContext().getRealm())) {
return get(m.getId());
}
try {
return get(m.getId());
} catch (ForbiddenException fe) {
return null;
}
}).filter(Objects::nonNull);
}
@Override
public boolean delete(String id) {
M model = getModel(id);
KeycloakContext context = session.getContext();
if (!context.hasPermission(model, getRealmResourceType(), AdminPermissionsSchema.MANAGE)) {
throw new ForbiddenException();
}
return onDelete(id);
}
@Override
public String getSchema() {
return schema.getName();
}
@Override
public List<String> getSchemaExtensions() {
return schemaExtensions.stream().map(ModelSchema::getName).toList();
}
protected abstract R onCreate(R resource);
protected abstract R onUpdate(M model, R resource);
protected abstract boolean onDelete(String id);
protected abstract Stream<M> getModels();
protected abstract M getModel(String id);
protected abstract String getRealmResourceType();
protected void populate(M model, R resource) {
for (ModelSchema<M, R> schema : schemas) {
if (resource.hasSchema(schema.getName())) {
schema.populate(model, resource);
}
}
}
private R createResourceTypeInstance() {
try {
return (R) getResourceType().getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("Could not create instance of resource type " + getResourceType(), e);
}
}
}
@@ -0,0 +1,101 @@
package org.keycloak.scim.resource.spi;
import java.util.List;
import java.util.stream.Stream;
import org.keycloak.provider.Provider;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
/**
* <p>A provider of a SCIM resource type.
*
* <p>This provider is responsible for the lifecycle of the resource type, including validation, creation, update,
* retrieval, and deletion of resources of that type. Once registered, the provider will be automatically available from
* the SCIM API.
*
* <p>A {@link ScimResourceTypeProvider}</p> is mainly responsible for mapping values from an SCIM resource representation
* to the underlying model and vice versa, and for enforcing the rules of the resource type and its corresponding model
* when managing resource type instances.
*/
public interface ScimResourceTypeProvider<R extends ResourceTypeRepresentation> extends Provider {
/**
* Returns the name of the resource type managed by this provider.
*
* @return the name of the resource type
*/
default String getName() {
return getResourceType().getSimpleName();
}
/**
* Returns the schema name of the resource type managed by this provider.
*
* @return the schema URI of the resource type
*/
String getSchema();
/**
* Returns the schema extensions names of the resource type managed by this provider.
*
* @return a list of schema extension URIs
*/
default List<String> getSchemaExtensions() {
return List.of();
}
/**
* Returns the {@link ResourceTypeRepresentation} type managed by this provider.
*
* @return the class of the resource type managed by this provider
*/
Class<R> getResourceType();
/**
* Creates a new resource of this type. This method is invoked after successful validation of the resource,
* and should persist the resource and return the persisted instance, including any generated identifier or metadata.
* The returned resource will be used in the response to the client.
*
* @param resource the resource to create
* @return the created resource
*/
R create(R resource);
/**
* Updates an existing resource of this type. This method is invoked after successful validation of the resource,
* and should persist the updated resource and return the persisted instance.
* The returned resource will be used in the response to the client.
*
* @param user the resource to update
* @return the updated resource
*/
R update(R user);
/**
* Retrieves a resource of this type by its identifier. This method is invoked when a client requests a specific resource,
* and should return the resource if it exists, or null if it does not exist.
* The returned resource will be used in the response to the client.
*
* @param id the identifier of the resource to retrieve
* @return the resource with the given identifier, or null if it does not exist
*/
R get(String id);
/**
* Retrieves all resources of this type. This method is invoked when a client requests a list of resources,
* and should return a stream of all resources of this type.
*
* TODO: this method should support pagination, filtering, and sorting in the future, but for now it returns all resources.
*
* @return a stream of all resources of this type
*/
Stream<R> getAll();
/**
* Deletes a resource of this type by its identifier. This method is invoked when a client requests the deletion of a specific resource,
*
* @param id the identifier of the resource to delete
* @return true if the resource was successfully deleted, false if the resource was not found or could not be deleted
*/
boolean delete(String id);
}
@@ -0,0 +1,15 @@
package org.keycloak.scim.resource.spi;
import org.keycloak.Config.Scope;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderFactory;
public interface ScimResourceTypeProviderFactory<P extends ScimResourceTypeProvider<?>> extends ProviderFactory<P>, EnvironmentDependentProviderFactory {
@Override
default boolean isSupported(Scope config) {
return Profile.isFeatureEnabled(Feature.SCIM_API);
}
}
@@ -0,0 +1,28 @@
package org.keycloak.scim.resource.spi;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ScimResourceTypeSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "scimResourceType";
}
@Override
public Class<? extends Provider> getProviderClass() {
return ScimResourceTypeProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return ScimResourceTypeProviderFactory.class;
}
}
@@ -0,0 +1,55 @@
package org.keycloak.scim.resource.spi;
import java.util.stream.Stream;
import org.keycloak.models.ModelValidationException;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
/**
* A specialization of {@link ScimResourceTypeProvider} for singleton SCIM resource types.
*
* <p>Singleton resource types represent resources of which only a single instance exists within the SCIM service provider.
* Examples of singleton resource types include the {@link org.keycloak.scim.resource.config.ServiceProviderConfig}.
*
* <p>This interface does not add any new methods to {@link ScimResourceTypeProvider}, but serves as a marker interface
* to indicate that the implementing provider manages a singleton resource type. Implementations of this interface should
* ensure that their behavior aligns with the semantics of singleton resources, such as returning a single instance
* in retrieval operations and handling creation and deletion appropriately.
*/
public interface SingletonResourceTypeProvider<R extends ResourceTypeRepresentation> extends ScimResourceTypeProvider<R> {
R getSingleton();
@Override
default R create(R resource) {
throw unsupportedOperation();
}
@Override
default R update(R user) {
throw unsupportedOperation();
}
@Override
default R get(String id) {
throw unsupportedOperation();
}
@Override
default Stream<R> getAll() {
return Stream.of(getSingleton());
}
@Override
default boolean delete(String id) {
throw unsupportedOperation();
}
private ModelValidationException unsupportedOperation() {
return new ModelValidationException("Unsupported operation for resource type " + getResourceType());
}
@Override
default void close() {
}
}
@@ -0,0 +1,114 @@
package org.keycloak.scim.resource.user;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class EnterpriseUser {
@JsonProperty("employeeNumber")
private String employeeNumber;
@JsonProperty("costCenter")
private String costCenter;
@JsonProperty("organization")
private String organization;
@JsonProperty("division")
private String division;
@JsonProperty("department")
private String department;
@JsonProperty("manager")
private Manager manager;
public String getEmployeeNumber() {
return employeeNumber;
}
public void setEmployeeNumber(String employeeNumber) {
this.employeeNumber = employeeNumber;
}
public String getCostCenter() {
return costCenter;
}
public void setCostCenter(String costCenter) {
this.costCenter = costCenter;
}
public String getOrganization() {
return organization;
}
public void setOrganization(String organization) {
this.organization = organization;
}
public String getDivision() {
return division;
}
public void setDivision(String division) {
this.division = division;
}
public String getDepartment() {
return department;
}
public void setDepartment(String department) {
this.department = department;
}
public Manager getManager() {
return manager;
}
public void setManager(Manager manager) {
this.manager = manager;
}
/**
* Represents the manager of the user as defined in RFC 7643 Section 4.3
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Manager {
@JsonProperty("value")
private String value;
@JsonProperty("$ref")
private String ref;
@JsonProperty("displayName")
private String displayName;
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
public String getRef() {
return ref;
}
public void setRef(String ref) {
this.ref = ref;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
}
}
@@ -0,0 +1,52 @@
package org.keycloak.scim.resource.user;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupMembership {
@JsonProperty("value")
private String value;
@JsonProperty("$ref")
private String ref;
@JsonProperty("display")
private String display;
@JsonProperty("type")
private String type;
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
public String getRef() {
return ref;
}
public void setRef(String ref) {
this.ref = ref;
}
public String getDisplay() {
return display;
}
public void setDisplay(String display) {
this.display = display;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
}
@@ -0,0 +1,308 @@
package org.keycloak.scim.resource.user;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.common.Address;
import org.keycloak.scim.resource.common.Email;
import org.keycloak.scim.resource.common.InstantMessagingAddress;
import org.keycloak.scim.resource.common.Name;
import org.keycloak.scim.resource.common.PhoneNumber;
import org.keycloak.scim.resource.common.Photo;
import org.keycloak.scim.resource.common.X509Certificate;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class User extends ResourceTypeRepresentation {
@JsonProperty("userName")
private String userName;
@JsonProperty("name")
private Name name;
@JsonProperty("displayName")
private String displayName;
@JsonProperty("nickName")
private String nickName;
@JsonProperty("profileUrl")
private String profileUrl;
@JsonProperty("title")
private String title;
@JsonProperty("userType")
private String userType;
@JsonProperty("preferredLanguage")
private String preferredLanguage;
@JsonProperty("locale")
private String locale;
@JsonProperty("timezone")
private String timezone;
@JsonProperty("active")
private Boolean active;
@JsonProperty("password")
private String password;
@JsonProperty("emails")
private List<Email> emails;
@JsonProperty("phoneNumbers")
private List<PhoneNumber> phoneNumbers;
@JsonProperty("ims")
private List<InstantMessagingAddress> ims;
@JsonProperty("photos")
private List<Photo> photos;
@JsonProperty("addresses")
private List<Address> addresses;
@JsonProperty("groups")
private List<GroupMembership> groups;
@JsonProperty("entitlements")
private List<String> entitlements;
@JsonProperty("roles")
private List<String> roles;
@JsonProperty("x509Certificates")
private List<X509Certificate> x509Certificates;
// Enterprise User Extension
@JsonProperty("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User")
private EnterpriseUser enterpriseUser;
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public Name getName() {
return name;
}
public void setName(Name name) {
this.name = name;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getNickName() {
return nickName;
}
public void setNickName(String nickName) {
this.nickName = nickName;
}
public String getProfileUrl() {
return profileUrl;
}
public void setProfileUrl(String profileUrl) {
this.profileUrl = profileUrl;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getUserType() {
return userType;
}
public void setUserType(String userType) {
this.userType = userType;
}
public String getPreferredLanguage() {
return preferredLanguage;
}
public void setPreferredLanguage(String preferredLanguage) {
this.preferredLanguage = preferredLanguage;
}
public String getLocale() {
return locale;
}
public void setLocale(String locale) {
this.locale = locale;
}
public String getTimezone() {
return timezone;
}
public void setTimezone(String timezone) {
this.timezone = timezone;
}
public Boolean getActive() {
return active;
}
public void setActive(Boolean active) {
this.active = active;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public List<Email> getEmails() {
return emails;
}
public void setEmails(List<Email> emails) {
this.emails = emails;
}
public List<PhoneNumber> getPhoneNumbers() {
return phoneNumbers;
}
public void setPhoneNumbers(List<PhoneNumber> phoneNumbers) {
this.phoneNumbers = phoneNumbers;
}
public List<InstantMessagingAddress> getIms() {
return ims;
}
public void setIms(List<InstantMessagingAddress> ims) {
this.ims = ims;
}
public List<Photo> getPhotos() {
return photos;
}
public void setPhotos(List<Photo> photos) {
this.photos = photos;
}
public List<Address> getAddresses() {
return addresses;
}
public void setAddresses(List<Address> addresses) {
this.addresses = addresses;
}
public List<GroupMembership> getGroups() {
return groups;
}
public void setGroups(List<GroupMembership> groups) {
this.groups = groups;
}
public List<String> getEntitlements() {
return entitlements;
}
public void setEntitlements(List<String> entitlements) {
this.entitlements = entitlements;
}
public List<String> getRoles() {
return roles;
}
public void setRoles(List<String> roles) {
this.roles = roles;
}
public List<X509Certificate> getX509Certificates() {
return x509Certificates;
}
public void setX509Certificates(List<X509Certificate> x509Certificates) {
this.x509Certificates = x509Certificates;
}
public EnterpriseUser getEnterpriseUser() {
return enterpriseUser;
}
public void setEnterpriseUser(EnterpriseUser enterpriseUser) {
this.enterpriseUser = enterpriseUser;
}
@JsonIgnore
public String getFirstName() {
return Optional.ofNullable(name).map(Name::getGivenName).orElse(null);
}
public void setFirstName(String firstName) {
name = Optional.ofNullable(name).orElseGet(Name::new);
name.setGivenName(firstName);
}
@JsonIgnore
public String getLastName() {
return Optional.ofNullable(name).map(Name::getFamilyName).orElse(null);
}
public void setLastName(String lastName) {
name = Optional.ofNullable(name).orElseGet(Name::new);
name.setFamilyName(lastName);
}
@JsonIgnore
public String getEmail() {
if (emails == null || emails.isEmpty()) {
return null;
}
return emails.get(0).getValue();
}
public void setEmail(String email) {
emails = List.of(new Email(email));
}
@Override
public Set<String> getSchemas() {
Set<String> schemas = super.getSchemas();
if (enterpriseUser != null) {
schemas.add(ENTERPRISE_USER_SCHEMA);
}
return schemas;
}
}
@@ -0,0 +1 @@
org.keycloak.scim.resource.spi.ScimResourceTypeSpi
@@ -0,0 +1,222 @@
package org.keycloak.scim.resource.response;
import java.util.List;
import org.keycloak.scim.protocol.response.ListResponse;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.group.Group;
import org.keycloak.scim.resource.user.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class ListResponseDeserializerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void testDeserializeUserListResponse() throws Exception {
String json = """
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 2,
"startIndex": 1,
"itemsPerPage": 2,
"Resources": [
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "user1",
"userName": "bjensen@example.com",
"name": {
"givenName": "Barbara",
"familyName": "Jensen"
},
"active": true
},
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "user2",
"userName": "jsmith@example.com",
"name": {
"givenName": "John",
"familyName": "Smith"
},
"active": true
}
]
}
""";
ListResponse<?> response = objectMapper.readValue(json, ListResponse.class);
assertNotNull(response);
assertEquals(2, response.getTotalResults());
assertEquals(1, response.getStartIndex());
assertEquals(2, response.getItemsPerPage());
List<? extends ResourceTypeRepresentation> resources = response.getResources();
assertNotNull(resources);
assertEquals(2, resources.size());
// Verify first resource is a User
assertTrue(resources.get(0) instanceof User);
User user1 = (User) resources.get(0);
assertEquals("user1", user1.getId());
assertEquals("bjensen@example.com", user1.getUserName());
assertEquals("Barbara", user1.getName().getGivenName());
assertEquals("Jensen", user1.getName().getFamilyName());
assertTrue(user1.getActive());
// Verify second resource is a User
assertTrue(resources.get(1) instanceof User);
User user2 = (User) resources.get(1);
assertEquals("user2", user2.getId());
assertEquals("jsmith@example.com", user2.getUserName());
}
@Test
void testDeserializeGroupListResponse() throws Exception {
String json = """
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 1,
"Resources": [
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"id": "group1",
"displayName": "Engineering",
"members": [
{
"value": "user1",
"display": "Barbara Jensen"
}
]
}
]
}
""";
ListResponse<?> response = objectMapper.readValue(json, ListResponse.class);
assertNotNull(response);
assertEquals(1, response.getTotalResults());
List<? extends ResourceTypeRepresentation> resources = response.getResources();
assertNotNull(resources);
assertEquals(1, resources.size());
// Verify resource is a Group
assertTrue(resources.get(0) instanceof Group);
Group group = (Group) resources.get(0);
assertEquals("group1", group.getId());
assertEquals("Engineering", group.getDisplayName());
assertNotNull(group.getMembers());
assertEquals(1, group.getMembers().size());
assertEquals("user1", group.getMembers().get(0).getValue());
assertEquals("Barbara Jensen", group.getMembers().get(0).getDisplay());
}
@Test
void testDeserializeMixedResourceTypes() throws Exception {
// This test demonstrates that resources can be of different types in the same response
// (though this is not common in SCIM, the deserializer supports it)
String json = """
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 2,
"Resources": [
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "user1",
"userName": "bjensen@example.com"
},
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"id": "group1",
"displayName": "Engineering"
}
]
}
""";
ListResponse<?> response = objectMapper.readValue(json, ListResponse.class);
assertNotNull(response);
assertEquals(2, response.getTotalResults());
List<? extends ResourceTypeRepresentation> resources = response.getResources();
assertNotNull(resources);
assertEquals(2, resources.size());
// First resource is User
assertTrue(resources.get(0) instanceof User);
User user = (User) resources.get(0);
assertEquals("user1", user.getId());
// Second resource is Group
assertTrue(resources.get(1) instanceof Group);
Group group = (Group) resources.get(1);
assertEquals("group1", group.getId());
}
@Test
void testDeserializeUserWithEnterpriseExtension() throws Exception {
String json = """
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 1,
"Resources": [
{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
],
"id": "user1",
"userName": "bjensen@example.com",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
"employeeNumber": "12345",
"department": "Engineering"
}
}
]
}
""";
ListResponse<?> response = objectMapper.readValue(json, ListResponse.class);
assertNotNull(response);
List<? extends ResourceTypeRepresentation> resources = response.getResources();
assertNotNull(resources);
assertEquals(1, resources.size());
// Verify it's a User with enterprise extension
assertTrue(resources.get(0) instanceof User);
User user = (User) resources.get(0);
assertEquals("user1", user.getId());
assertNotNull(user.getEnterpriseUser());
assertEquals("12345", user.getEnterpriseUser().getEmployeeNumber());
assertEquals("Engineering", user.getEnterpriseUser().getDepartment());
}
@Test
void testDeserializeEmptyListResponse() throws Exception {
String json = """
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 0,
"Resources": []
}
""";
ListResponse<?> response = objectMapper.readValue(json, ListResponse.class);
assertNotNull(response);
assertEquals(0, response.getTotalResults());
assertNotNull(response.getResources());
assertTrue(response.getResources().isEmpty());
}
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-parent</artifactId>
<groupId>org.keycloak</groupId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-scim-model</artifactId>
<name>Keycloak SCIM Core</name>
<description>
This module provides a Model API around the SCIM Core Schema(RFC7643)
</description>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-scim-core</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,40 @@
package org.keycloak.scim.model.config;
import java.util.List;
import org.keycloak.common.util.Time;
import org.keycloak.scim.resource.config.ServiceProviderConfig;
import org.keycloak.scim.resource.config.ServiceProviderConfig.BulkSupport;
import org.keycloak.scim.resource.config.ServiceProviderConfig.FilterSupport;
import org.keycloak.scim.resource.config.ServiceProviderConfig.Supported;
import org.keycloak.scim.resource.spi.SingletonResourceTypeProvider;
public class ServiceProviderConfigResourceTypeProvider implements SingletonResourceTypeProvider<ServiceProviderConfig> {
@Override
public ServiceProviderConfig getSingleton() {
ServiceProviderConfig config = new ServiceProviderConfig();
config.setId("");
config.setBulk(new BulkSupport());
config.setPatch(Supported.FALSE);
config.setEtag(Supported.FALSE);
config.setAuthenticationSchemes(List.of());
config.setChangePassword(Supported.FALSE);
config.setCreatedTimestamp(Time.currentTimeMillis());
config.setSort(Supported.FALSE);
config.setFilter(new FilterSupport());
return config;
}
@Override
public Class<ServiceProviderConfig> getResourceType() {
return ServiceProviderConfig.class;
}
@Override
public String getSchema() {
return ServiceProviderConfig.SCHEMA;
}
}
@@ -0,0 +1,36 @@
package org.keycloak.scim.model.config;
import org.keycloak.Config.Scope;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.scim.resource.spi.ScimResourceTypeProviderFactory;
public class ServiceProviderConfigResourceTypeProviderFactory implements ScimResourceTypeProviderFactory<ServiceProviderConfigResourceTypeProvider> {
public static final ServiceProviderConfigResourceTypeProvider INSTANCE = new ServiceProviderConfigResourceTypeProvider();
@Override
public ServiceProviderConfigResourceTypeProvider create(KeycloakSession session) {
return INSTANCE;
}
@Override
public void init(Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "ServiceProviderConfig";
}
}
@@ -0,0 +1,55 @@
package org.keycloak.scim.model.group;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.keycloak.models.GroupModel;
import org.keycloak.scim.resource.group.Group;
import org.keycloak.scim.resource.schema.AbstractModelSchema;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import org.keycloak.scim.resource.schema.attribute.AttributeMapper;
public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel, Group> {
private static final List<Attribute<GroupModel, Group>> ATTRIBUTE_MAPPERS = new ArrayList<>();
static {
ATTRIBUTE_MAPPERS.add(new Attribute<>("displayName", new AttributeMapper<>(GroupModel::setName, Group::setDisplayName)));
}
public GroupCoreModelSchema() {
super(Group.SCHEMA, ATTRIBUTE_MAPPERS);
}
@Override
public String getName() {
return Group.SCHEMA;
}
@Override
protected Set<String> getAttributeNames(GroupModel model) {
return Set.of("name");
}
@Override
protected String getAttributeValue(GroupModel model, String name) {
if (name.equals("name")) {
return model.getName();
}
return null;
}
@Override
protected String getAttributeSchema(GroupModel model, String name) {
return "urn:ietf:params:scim:schemas:core:2.0:Group";
}
@Override
protected String getAttributeSchemaName(GroupModel model, String name) {
if (name.equals("name")) {
return "displayName";
}
return null;
}
}
@@ -0,0 +1,73 @@
/*
* Copyright 2025 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.scim.model.group;
import java.util.stream.Stream;
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.scim.resource.group.Group;
import org.keycloak.scim.resource.spi.AbstractScimResourceTypeProvider;
public class GroupResourceTypeProvider extends AbstractScimResourceTypeProvider<GroupModel, Group> {
public GroupResourceTypeProvider(KeycloakSession session) {
super(session, new GroupCoreModelSchema());
}
@Override
public Group onCreate(Group group) {
RealmModel realm = session.getContext().getRealm();
GroupModel model = session.groups().createGroup(realm, group.getDisplayName());
populate(model, group);
return group;
}
@Override
protected Group onUpdate(GroupModel model, Group resource) {
return resource;
}
@Override
protected GroupModel getModel(String id) {
RealmModel realm = session.getContext().getRealm();
return session.groups().getGroupById(realm, id);
}
@Override
protected String getRealmResourceType() {
return AdminPermissionsSchema.GROUPS_RESOURCE_TYPE;
}
@Override
protected Stream<GroupModel> getModels() {
RealmModel realm = session.getContext().getRealm();
return session.groups().getTopLevelGroupsStream(realm);
}
@Override
public boolean onDelete(String id) {
RealmModel realm = session.getContext().getRealm();
return session.groups().removeGroup(realm, getModel(id));
}
@Override
public Class<Group> getResourceType() {
return Group.class;
}
@Override
public void close() {
}
}
@@ -0,0 +1,44 @@
/*
* Copyright 2025 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.scim.model.group;
import org.keycloak.Config.Scope;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.scim.resource.spi.ScimResourceTypeProviderFactory;
public class GroupResourceTypeProviderFactory implements ScimResourceTypeProviderFactory<GroupResourceTypeProvider> {
@Override
public GroupResourceTypeProvider create(KeycloakSession session) {
return new GroupResourceTypeProvider(session);
}
@Override
public void init(Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "Groups";
}
}
@@ -0,0 +1,100 @@
package org.keycloak.scim.model.resourcetype;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
import org.keycloak.models.KeycloakSession;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.config.ServiceProviderConfig;
import org.keycloak.scim.resource.resourcetype.ResourceType;
import org.keycloak.scim.resource.resourcetype.ResourceType.SchemaExtension;
import org.keycloak.scim.resource.spi.ScimResourceTypeProvider;
import org.keycloak.scim.resource.spi.ScimResourceTypeProviderFactory;
public class ResourceTypeProvider implements ScimResourceTypeProvider<ResourceType> {
private static final List<Class<? extends ResourceTypeRepresentation>> EXCLUDED_RESOURCE_TYPES = List.of(ServiceProviderConfig.class, ResourceType.class);
private final KeycloakSession session;
public ResourceTypeProvider(KeycloakSession session) {
this.session = session;
}
@Override
public void close() {
}
@Override
public Class<ResourceType> getResourceType() {
return ResourceType.class;
}
@Override
public ResourceType create(ResourceType resource) {
throw new UnsupportedOperationException("Not supported yet.");
}
@Override
public ResourceType update(ResourceType user) {
throw new UnsupportedOperationException("Not supported yet.");
}
@Override
public ResourceType get(String id) {
throw new UnsupportedOperationException("Not supported yet.");
}
@Override
public Stream<ResourceType> getAll() {
return session.getKeycloakSessionFactory().getProviderFactoriesStream(ScimResourceTypeProvider.class)
.map(ScimResourceTypeProviderFactory.class::cast)
.map(this::toRepresentation)
.filter(Objects::nonNull);
}
private ResourceType toRepresentation(ScimResourceTypeProviderFactory<? extends ScimResourceTypeProvider<? extends ResourceTypeRepresentation>> factory) {
ScimResourceTypeProvider<? extends ResourceTypeRepresentation> provider = factory.create(session);
if (EXCLUDED_RESOURCE_TYPES.contains(provider.getResourceType())) {
return null;
}
ResourceType representation = new ResourceType();
ResourceTypeRepresentation resourceType;
try {
resourceType = provider.getResourceType().getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("Could not instantiate resource type representation for provider " + factory.getId(), e);
}
representation.setName(provider.getName());
representation.setEndpoint("/" + factory.getId());
representation.setSchema(provider.getSchema());
List<SchemaExtension> schemaExtensions = new ArrayList<>();
for (String name : provider.getSchemaExtensions()) {
SchemaExtension extension = new SchemaExtension();
extension.setSchema(name);
schemaExtensions.add(extension);
}
representation.setSchemaExtensions(schemaExtensions);
return representation;
}
@Override
public boolean delete(String id) {
throw new UnsupportedOperationException("Not supported yet.");
}
@Override
public String getSchema() {
return ResourceType.SCHEMA;
}
}
@@ -0,0 +1,34 @@
package org.keycloak.scim.model.resourcetype;
import org.keycloak.Config.Scope;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.scim.resource.spi.ScimResourceTypeProviderFactory;
public class ResourceTypeProviderFactory implements ScimResourceTypeProviderFactory<ResourceTypeProvider> {
@Override
public ResourceTypeProvider create(KeycloakSession session) {
return new ResourceTypeProvider(session);
}
@Override
public void init(Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "ResourceTypes";
}
}
@@ -0,0 +1,82 @@
package org.keycloak.scim.model.user;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.scim.resource.schema.AbstractModelSchema;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import org.keycloak.scim.resource.user.User;
import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileProvider;
public abstract class AbstractUserModelSchema extends AbstractModelSchema<UserModel ,User> {
public static final String ANNOTATION_SCIM_SCHEMA = "scim.schema";
public static final String ANNOTATION_SCIM_SCHEMA_ATTRIBUTE = "scim.schema.attribute";
private final KeycloakSession session;
public AbstractUserModelSchema(KeycloakSession session, String name, List<Attribute<UserModel, User>> attributeMappers) {
super(name, attributeMappers);
this.session = session;
}
@Override
protected Set<String> getAttributeNames(UserModel model) {
Set<String> names = new HashSet<>(getAttributes(model).nameSet());
names.add(UserModel.ENABLED);
return names;
}
@Override
protected String getAttributeSchema(UserModel model, String name) {
Object schema = getAttributeAnnotations(model, name).get(ANNOTATION_SCIM_SCHEMA);
if (schema == null) {
return null;
}
return String.valueOf(schema);
}
@Override
protected String getAttributeSchemaName(UserModel model, String name) {
Object schema = getAttributeAnnotations(model, name).get(ANNOTATION_SCIM_SCHEMA_ATTRIBUTE);
if (schema == null) {
return null;
}
return String.valueOf(schema);
}
@Override
protected String getAttributeValue(UserModel model, String name) {
if (UserModel.ENABLED.equals(name)) {
return String.valueOf(model.isEnabled());
}
return getAttributes(model).getFirst(name);
}
private Map<String, Object> getAttributeAnnotations(UserModel model, String name) {
AttributeMetadata metadata = getAttributes(model).getMetadata(name);
if (metadata == null) {
return Map.of();
}
return Optional.ofNullable(metadata.getAnnotations()).orElse(Map.of());
}
private Attributes getAttributes(UserModel model) {
UserProfile profile = session.getProvider(UserProfileProvider.class).create(UserProfileContext.SCIM, model);
return profile.getAttributes();
}
}
@@ -0,0 +1,14 @@
package org.keycloak.scim.model.user;
import java.util.function.BiConsumer;
import org.keycloak.models.UserModel;
import org.keycloak.scim.resource.schema.attribute.AttributeMapper;
import org.keycloak.scim.resource.user.User;
public class UserAttributeMapper extends AttributeMapper<UserModel, User> {
public UserAttributeMapper(BiConsumer<User, String> setter) {
super(UserModel::setSingleAttribute, setter);
}
}
@@ -0,0 +1,37 @@
package org.keycloak.scim.model.user;
import java.util.ArrayList;
import java.util.List;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.scim.resource.Scim;
import org.keycloak.scim.resource.common.Name;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import org.keycloak.scim.resource.schema.attribute.AttributeMapper;
import org.keycloak.scim.resource.user.User;
public final class UserCoreModelSchema extends AbstractUserModelSchema {
private static final List<Attribute<UserModel, User>> ATTRIBUTE_MAPPERS = new ArrayList<>();
static {
ATTRIBUTE_MAPPERS.add(new Attribute<>("userName", new UserAttributeMapper(User::setUserName)));
ATTRIBUTE_MAPPERS.add(new Attribute<>("emails[0].value", new UserAttributeMapper(User::setEmail)));
ATTRIBUTE_MAPPERS.add(new Attribute<>("name.givenName", new UserAttributeMapper(User::setFirstName)));
ATTRIBUTE_MAPPERS.add(new Attribute<>("name.familyName", new UserAttributeMapper(User::setLastName)));
ATTRIBUTE_MAPPERS.add(new Attribute<>("name.middleName", new UserNameAttributeMapper(Name::setMiddleName)));
ATTRIBUTE_MAPPERS.add(new Attribute<>("name.honorificPrefix", new UserNameAttributeMapper(Name::setHonorificPrefix)));
ATTRIBUTE_MAPPERS.add(new Attribute<>("name.honorificSuffix", new UserNameAttributeMapper(Name::setHonorificSuffix)));
ATTRIBUTE_MAPPERS.add(new Attribute<>("externalId", new UserAttributeMapper(User::setExternalId)));
ATTRIBUTE_MAPPERS.add(new Attribute<>("nickName", new UserAttributeMapper(User::setNickName)));
ATTRIBUTE_MAPPERS.add(new Attribute<>("locale", new UserAttributeMapper(User::setLocale)));
ATTRIBUTE_MAPPERS.add(new Attribute<>("active", new AttributeMapper<>(
(model, value) -> model.setEnabled(Boolean.parseBoolean(value)),
(user, value) -> user.setActive(Boolean.parseBoolean(value)))));
}
public UserCoreModelSchema(KeycloakSession session) {
super(session, Scim.getCoreSchema(User.class), ATTRIBUTE_MAPPERS);
}
}
@@ -0,0 +1,87 @@
package org.keycloak.scim.model.user;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import org.keycloak.scim.resource.user.EnterpriseUser;
import org.keycloak.scim.resource.user.EnterpriseUser.Manager;
import org.keycloak.scim.resource.user.User;
import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA;
public final class UserEnterpriseModelSchema extends AbstractUserModelSchema {
private static final List<Attribute<UserModel, User>> ATTRIBUTE_MAPPERS = new ArrayList<>();
static {
ATTRIBUTE_MAPPERS.add(new Attribute<>("employeeNumber", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper(EnterpriseUser::setEmployeeNumber))));
ATTRIBUTE_MAPPERS.add(new Attribute<>("costCenter", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper(EnterpriseUser::setCostCenter))));
ATTRIBUTE_MAPPERS.add(new Attribute<>("organization", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper(EnterpriseUser::setOrganization))));
ATTRIBUTE_MAPPERS.add(new Attribute<>("division", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper(EnterpriseUser::setDivision))));
ATTRIBUTE_MAPPERS.add(new Attribute<>("department", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper(EnterpriseUser::setDepartment))));
ATTRIBUTE_MAPPERS.add(new Attribute<>("manager.value", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper((user, value) -> {
Manager manager = user.getManager();
if (manager == null) {
manager = new Manager();
user.setManager(manager);
}
manager.setValue(value);
}))));
ATTRIBUTE_MAPPERS.add(new Attribute<>("manager.displayName", new UserAttributeMapper(new EnterpriseUserResourceTypeAttributeMapper((user, value) -> {
Manager manager = user.getManager();
if (manager == null) {
manager = new Manager();
user.setManager(manager);
}
manager.setDisplayName(value);
}))));
}
public UserEnterpriseModelSchema(KeycloakSession session) {
super(session, ENTERPRISE_USER_SCHEMA, ATTRIBUTE_MAPPERS);
}
@Override
public String getName() {
return ENTERPRISE_USER_SCHEMA;
}
@Override
public Map<String, Attribute<UserModel, User>> getAttributes() {
return Map.of();
}
private static class EnterpriseUserResourceTypeAttributeMapper implements BiConsumer<User, String> {
private final BiConsumer<EnterpriseUser, String> setter;
public EnterpriseUserResourceTypeAttributeMapper(BiConsumer<EnterpriseUser, String> setter) {
this.setter = setter;
}
@Override
public void accept(User user, String value) {
if (value == null) {
return;
}
EnterpriseUser enterpriseUser = user.getEnterpriseUser();
if (enterpriseUser == null) {
enterpriseUser = new EnterpriseUser();
user.setEnterpriseUser(enterpriseUser);
}
setter.accept(enterpriseUser, value);
}
}
}
@@ -0,0 +1,28 @@
package org.keycloak.scim.model.user;
import java.util.function.BiConsumer;
import org.keycloak.models.UserModel;
import org.keycloak.scim.resource.common.Name;
import org.keycloak.scim.resource.schema.attribute.AttributeMapper;
import org.keycloak.scim.resource.user.User;
public class UserNameAttributeMapper extends AttributeMapper<UserModel, User> {
public UserNameAttributeMapper(BiConsumer<Name, String> setter) {
super(UserModel::setSingleAttribute, (user, value) -> {
if (value == null) {
return;
}
Name name = user.getName();
if (name == null) {
name = new Name();
user.setName(name);
}
setter.accept(name, value);
});
}
}
@@ -0,0 +1,104 @@
package org.keycloak.scim.model.user;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelValidationException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.scim.resource.spi.AbstractScimResourceTypeProvider;
import org.keycloak.scim.resource.user.User;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.ValidationException;
import org.keycloak.userprofile.ValidationException.Error;
public class UserResourceTypeProvider extends AbstractScimResourceTypeProvider<UserModel, User> {
public UserResourceTypeProvider(KeycloakSession session) {
super(session, new UserCoreModelSchema(session), List.of(new UserEnterpriseModelSchema(session)));
}
@Override
public User onCreate(User resource) {
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UserProfile profile = provider.create(UserProfileContext.SCIM, Map.of(UserModel.USERNAME, resource.getUserName()));
UserModel model = profile.create(false);
populate(model, resource);
try {
profile = provider.create(UserProfileContext.SCIM, model);
profile.validate();
} catch (ValidationException ve) {
throw handleValidationException(ve);
}
return resource;
}
@Override
protected User onUpdate(UserModel model, User resource) {
try {
UserProfileProvider userProfileProvider = session.getProvider(UserProfileProvider.class);
UserProfile profile = userProfileProvider.create(UserProfileContext.SCIM, model);
profile.update();
} catch (ValidationException ve) {
throw handleValidationException(ve);
}
return resource;
}
@Override
protected UserModel getModel(String id) {
RealmModel realm = session.getContext().getRealm();
return session.users().getUserById(realm, id);
}
@Override
protected String getRealmResourceType() {
return AdminPermissionsSchema.USERS_RESOURCE_TYPE;
}
@Override
protected Stream<UserModel> getModels() {
RealmModel realm = session.getContext().getRealm();
return session.users().searchForUserStream(realm, Map.of());
}
@Override
public Class<User> getResourceType() {
return User.class;
}
@Override
public boolean onDelete(String id) {
RealmModel realm = session.getContext().getRealm();
return session.users().removeUser(realm, getModel(id));
}
@Override
public void close() {
}
private ModelValidationException handleValidationException(ValidationException ve) {
List<Error> errors = ve.getErrors();
if (errors.isEmpty()) {
throw new ModelValidationException(ve.getMessage());
}
Error firstError = errors.get(0);
ModelValidationException exception = new ModelValidationException(firstError.getMessage());
exception.setParameters(firstError.getMessageParameters());
return exception;
}
}
@@ -0,0 +1,34 @@
package org.keycloak.scim.model.user;
import org.keycloak.Config.Scope;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.scim.resource.spi.ScimResourceTypeProviderFactory;
public class UserResourceTypeProviderFactory implements ScimResourceTypeProviderFactory<UserResourceTypeProvider> {
@Override
public UserResourceTypeProvider create(KeycloakSession session) {
return new UserResourceTypeProvider(session);
}
@Override
public void init(Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "Users";
}
}
@@ -0,0 +1,4 @@
org.keycloak.scim.model.user.UserResourceTypeProviderFactory
org.keycloak.scim.model.group.GroupResourceTypeProviderFactory
org.keycloak.scim.model.config.ServiceProviderConfigResourceTypeProviderFactory
org.keycloak.scim.model.resourcetype.ResourceTypeProviderFactory
Executable
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-parent</artifactId>
<groupId>org.keycloak</groupId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-scim-parent</artifactId>
<packaging>pom</packaging>
<name>Keycloak SCIM Parent</name>
<description>
The parent module for SCIM (System for Cross-domain Identity Management) support in Keycloak.
</description>
<modules>
<module>core</module>
<module>model</module>
<module>client</module>
<module>services</module>
<module>tests</module>
</modules>
</project>
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-parent</artifactId>
<groupId>org.keycloak</groupId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-scim-services</artifactId>
<name>Keycloak SCIM Client</name>
<description>
This module provides the SCIM Services based on the SCIM Protocol(RFC7644)
</description>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-scim-core</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-scim-model</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
</dependency>
<dependency>
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,30 @@
package org.keycloak.scim.services;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.keycloak.models.KeycloakSession;
import org.keycloak.scim.protocol.response.ErrorResponse;
import org.keycloak.scim.resource.spi.ScimResourceTypeProvider;
public class ScimRealmResource {
private final KeycloakSession session;
public ScimRealmResource(KeycloakSession session) {
this.session = session;
}
@Path("/v2/{resourceType}")
public Object resourceType(@PathParam("resourceType") String resourceType) {
ScimResourceTypeProvider<?> provider = session.getProvider(ScimResourceTypeProvider.class, resourceType);// Ensure the provider is loaded
if (provider == null) {
return Response.status(Response.Status.NOT_FOUND).entity(new ErrorResponse("Resource type not found", Status.NOT_FOUND.getStatusCode())).build();
}
return new ScimResourceTypeResource<>(session, provider);
}
}
@@ -0,0 +1,71 @@
package org.keycloak.scim.services;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.keycloak.Config.Scope;
import org.keycloak.Token;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
public class ScimRealmResourceFactory implements RealmResourceProviderFactory, EnvironmentDependentProviderFactory {
@Override
public RealmResourceProvider create(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
if (realm.isScimEnabled()) {
return new RealmResourceProvider() {
@Override
public Object getResource() {
Token bearerToken = session.getContext().getBearerToken();
if (bearerToken == null) {
return Response.status(Status.UNAUTHORIZED).build();
}
return new ScimRealmResource(session);
}
@Override
public void close() {
}
};
}
return null;
}
@Override
public void init(Scope config) {
config.toString();
}
@Override
public void postInit(KeycloakSessionFactory factory) {
factory.toString();
}
@Override
public void close() {
}
@Override
public String getId() {
return "scim";
}
@Override
public boolean isSupported(Scope config) {
return Profile.isFeatureEnabled(Feature.SCIM_API);
}
}
@@ -0,0 +1,213 @@
package org.keycloak.scim.services;
import java.io.IOException;
import java.io.InputStream;
import java.text.MessageFormat;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.function.BiFunction;
import java.util.stream.Stream;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelValidationException;
import org.keycloak.scim.protocol.ForbiddenException;
import org.keycloak.scim.protocol.response.ErrorResponse;
import org.keycloak.scim.protocol.response.ListResponse;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.common.Meta;
import org.keycloak.scim.resource.spi.ScimResourceTypeProvider;
import org.keycloak.scim.resource.spi.SingletonResourceTypeProvider;
import org.keycloak.theme.Theme;
import org.keycloak.util.JsonSerialization;
public class ScimResourceTypeResource<R extends ResourceTypeRepresentation> {
private static final String APPLICATION_SCIM_JSON = "application/scim+json";
private final KeycloakSession session;
private final ScimResourceTypeProvider<R> resourceTypeProvider;
private final Class<? extends ResourceTypeRepresentation> resourceTypeClazz;
public ScimResourceTypeResource(KeycloakSession session, ScimResourceTypeProvider<R> resourceTypeProvider) {
this.session = session;
this.resourceTypeProvider = resourceTypeProvider;
this.resourceTypeClazz = resourceTypeProvider.getResourceType();
}
@POST
@Consumes({APPLICATION_SCIM_JSON, MediaType.APPLICATION_JSON})
@Produces(APPLICATION_SCIM_JSON)
public Response create(InputStream is) {
R resource = parseResourceTypePayload(is);
if (resource.getId() != null) {
return badRequest("Unexpected identifier");
}
return onPersist(resource, Status.CREATED,
(rScimResourceTypeProvider, r) -> resourceTypeProvider.create(r));
}
@Path("{id}")
@GET
@Produces(APPLICATION_SCIM_JSON)
public Response get(@PathParam("id") String id) {
R resource = getResource(id);
if (resource == null) {
return resourceNotFound(id);
}
setMetadata(resource, resource.getCreatedTimestamp());
return Response.ok().entity(resource).build();
}
@GET
@Produces(APPLICATION_SCIM_JSON)
public Response getAll() {
Stream<R> stream = resourceTypeProvider.getAll().peek((r -> setMetadata(r, r.getCreatedTimestamp())));
if (resourceTypeProvider instanceof SingletonResourceTypeProvider<R>) {
return Response.ok().entity(stream
.peek(r -> setMetadata(r, r.getCreatedTimestamp()))
.findAny().orElseThrow(NotFoundException::new))
.build();
}
List<R> resources = stream.toList();
ListResponse<R> response = new ListResponse<>();
response.setResources(resources);
// TODO: need to implement pagination and filtering, for now we just return all resources and set totalResults accordingly
response.setTotalResults(response.getResources().size());
return Response.ok().entity(response).build();
}
@Path("{id}")
@DELETE
@Produces(APPLICATION_SCIM_JSON)
public Response delete(@PathParam("id") String id) {
R resource = getResource(id);
if (resource == null) {
return resourceNotFound(id);
}
if (resourceTypeProvider.delete(id)) {
return Response.noContent().build();
}
return badRequest("Could not delete resource not found with id " + id);
}
@Path("{id}")
@PUT
@Consumes({APPLICATION_SCIM_JSON, MediaType.APPLICATION_JSON})
@Produces(APPLICATION_SCIM_JSON)
public Response update(@PathParam("id") String id, InputStream is) {
R existing = getResource(id);
if (existing == null) {
return resourceNotFound(id);
}
R resource = parseResourceTypePayload(is);
return onPersist(resource, Status.OK,
(rScimResourceTypeProvider, r) -> resourceTypeProvider.update(r));
}
private R parseResourceTypePayload(InputStream is) {
try {
return (R) JsonSerialization.readValue(is, resourceTypeClazz);
} catch (IOException e) {
throw new RuntimeException("Failed to deserialize request body", e);
}
}
private void setMetadata(R resource, long createdTimestamp) {
Meta meta = new Meta();
meta.setResourceType(resourceTypeProvider.getName());
meta.setCreated(Instant.ofEpochMilli(createdTimestamp).toString());
meta.setLastModified(meta.getCreated());
UriBuilder location = session.getContext().getUri().getAbsolutePathBuilder();
if (resource.getId() != null) {
location.path(resource.getId());
}
meta.setLocation(location.build().toString());
resource.setMeta(meta);
}
private Properties getMessageBundle(String lang) {
try {
Theme theme = session.theme().getTheme(Theme.Type.ADMIN);
Locale locale = lang != null ? Locale.forLanguageTag(lang) : Locale.ENGLISH;
return theme.getMessages(locale);
} catch (IOException e) {
return new Properties();
}
}
private Response onPersist(R resource, Status status, BiFunction<ScimResourceTypeProvider<R>, R, R> consumer) {
try {
R r = consumer.apply(resourceTypeProvider, resource);
setMetadata(resource, Time.currentTimeMillis());
return Response.status(status).entity(resource).build();
} catch (ModelValidationException mve) {
String language = session.getContext().getRequestHeaders().getHeaderString(HttpHeaders.ACCEPT_LANGUAGE);
Properties messages = getMessageBundle(language);
String format = messages.getProperty(mve.getMessage(), mve.getMessage())
.replace("{{", "{").replace("}}", "}")
.replace("'", "");
String message = MessageFormat.format(format, mve.getParameters());
session.getTransactionManager().setRollbackOnly();
return badRequest(message);
} catch (ForbiddenException fbe) {
return Response.status(Status.FORBIDDEN).build();
}
}
private R getResource(String id) {
try {
return resourceTypeProvider.get(id);
} catch (ForbiddenException fe) {
throw new jakarta.ws.rs.ForbiddenException(fe);
}
}
private Response resourceNotFound(String id) {
return errorResponse(Status.NOT_FOUND, "Resource not found with id " + id);
}
private Response badRequest(String message) {
return errorResponse(Status.BAD_REQUEST, message);
}
private Response errorResponse(Status status, String message) {
return Response.status(status).entity(new ErrorResponse(message, status.getStatusCode())).build();
}
}
@@ -0,0 +1 @@
org.keycloak.scim.services.ScimRealmResourceFactory
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-scim-tests-parent</artifactId>
<groupId>org.keycloak.tests</groupId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-scim-tests-base</artifactId>
<name>Keycloak SCIM Base Testsuite</name>
<packaging>jar</packaging>
<description>Keycloak SCIM Base Testsuite</description>
<dependencies>
<dependency>
<groupId>org.keycloak.tests</groupId>
<artifactId>keycloak-test-framework-scim-client</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,25 @@
/*
* Copyright 2025 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.tests.scim.tck;
import org.keycloak.scim.client.ScimClient;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.scim.client.annotations.InjectScimClient;
public abstract class AbstractScimTest {
@InjectRealm(config = ScimRealmConfig.class)
ManagedRealm realm;
@InjectScimClient
ScimClient client;
}
@@ -0,0 +1,66 @@
package org.keycloak.tests.scim.tck;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.scim.resource.group.Group;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@KeycloakIntegrationTest(config = ScimServerConfig.class)
public class GroupTest extends AbstractScimTest {
@Test
public void testCreate() {
Group expected = new Group();
expected.setDisplayName(KeycloakModelUtils.generateId());
expected = client.groups().create(expected);
assertNotNull(expected);
Group actual = client.groups().get(expected.getId());
assertNotNull(actual);
assertEquals(expected.getDisplayName(), actual.getDisplayName());
}
@Test
public void testDelete() {
Group expected = new Group();
expected.setDisplayName(KeycloakModelUtils.generateId());
expected = client.groups().create(expected);
assertNotNull(expected);
client.groups().delete(expected.getId());
expected = client.groups().get(expected.getId());
assertNull(expected);
}
@Test
public void testUpdate() {
Group expected = new Group();
expected.setDisplayName(KeycloakModelUtils.generateId());
expected = client.groups().create(expected);
assertNotNull(expected);
expected = client.groups().get(expected.getId());
expected.setDisplayName("Updated " + expected.getDisplayName());
client.groups().update(expected);
Group actual = client.groups().get(expected.getId());
assertEquals(expected.getDisplayName(), actual.getDisplayName());
}
@Test
public void testGetExisting() {
GroupRepresentation rep = new GroupRepresentation();
rep.setName(KeycloakModelUtils.generateId());
realm.admin().groups().add(rep).close();
rep = realm.admin().groups().groups(rep.getName(), -1, -1).get(0);
Group group = client.groups().get(rep.getId());
assertNotNull(group);
assertEquals(rep.getName(), group.getDisplayName());
}
}
@@ -0,0 +1,36 @@
package org.keycloak.tests.scim.tck;
import jakarta.ws.rs.core.Response.Status;
import org.keycloak.scim.client.ScimClient;
import org.keycloak.scim.client.ScimClientException;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.scim.client.annotations.InjectScimClient;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
@KeycloakIntegrationTest(config = ScimServerConfig.class)
public class RealmConfigTest extends AbstractScimTest {
@InjectRealm
ManagedRealm realm;
@InjectScimClient
ScimClient client;
@Test
public void testFeatureDisabled() {
try {
client.config().get();
fail("Expected exception when retrieving service provider config");
} catch (ScimClientException e) {
assertEquals(Status.NOT_FOUND.getStatusCode(), e.getError().getStatusInt());
}
}
}
@@ -0,0 +1,48 @@
package org.keycloak.tests.scim.tck;
import java.util.List;
import org.keycloak.scim.protocol.response.ListResponse;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.group.Group;
import org.keycloak.scim.resource.resourcetype.ResourceType;
import org.keycloak.scim.resource.resourcetype.ResourceType.SchemaExtension;
import org.keycloak.scim.resource.user.User;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.junit.jupiter.api.Test;
import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA;
import static org.keycloak.scim.resource.Scim.getCoreSchema;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest(config = ScimServerConfig.class)
public class ResourceTypeTest extends AbstractScimTest {
@Test
public void testGet() {
ListResponse<ResourceType> response = client.resourceTypes().getAll();
assertNotNull(response);
assertEquals(2, response.getResources().size());
assertResourceType(response, User.class, List.of(ENTERPRISE_USER_SCHEMA));
assertResourceType(response, Group.class, List.of());
}
private static void assertResourceType(ListResponse<ResourceType> response, Class<? extends ResourceTypeRepresentation> resourceType, List<String> expectedSchemaExtensions) {
ResourceType representation = response.getResources().stream().filter(r -> r.getName().equals(resourceType.getSimpleName())).findAny().orElse(null);
assertNotNull(representation);
assertEquals("/" + representation.getName() + "s", representation.getEndpoint());
assertEquals(getCoreSchema(resourceType), representation.getSchema());
if (!expectedSchemaExtensions.isEmpty()) {
assertEquals(expectedSchemaExtensions.size(), representation.getSchemaExtensions().size());
assertTrue(representation.getSchemaExtensions().stream().map(SchemaExtension::getSchema).toList().containsAll(expectedSchemaExtensions));
}
}
}
@@ -0,0 +1,12 @@
package org.keycloak.tests.scim.tck;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.RealmConfigBuilder;
public class ScimRealmConfig implements RealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder realm) {
return realm.scimEnabled(true);
}
}
@@ -0,0 +1,13 @@
package org.keycloak.tests.scim.tck;
import org.keycloak.common.Profile.Feature;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
public class ScimServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Feature.SCIM_API);
}
}
@@ -0,0 +1,47 @@
package org.keycloak.tests.scim.tck;
import java.util.List;
import java.util.Set;
import org.keycloak.scim.resource.config.ServiceProviderConfig;
import org.keycloak.scim.resource.config.ServiceProviderConfig.AuthenticationScheme;
import org.keycloak.scim.resource.config.ServiceProviderConfig.BulkSupport;
import org.keycloak.scim.resource.config.ServiceProviderConfig.Supported;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest(config = ScimServerConfig.class)
public class ServiceProviderConfigTest extends AbstractScimTest {
@Test
public void testFeatureDisabled() {
ServiceProviderConfig config = client.config().get();
assertNotNull(config);
BulkSupport bulk = config.getBulk();
assertNotNull(bulk);
assertFalse(bulk.getSupported());
Supported etag = config.getEtag();
assertNotNull(etag);
assertFalse(etag.getSupported());
Supported changePassword = config.getChangePassword();
assertNotNull(changePassword);
assertFalse(changePassword.getSupported());
Supported patch = config.getPatch();
assertNotNull(patch);
assertFalse(patch.getSupported());
List<AuthenticationScheme> authenticationSchemes = config.getAuthenticationSchemes();
assertNotNull(authenticationSchemes);
// TODO: support at least bearer token authentication scheme
assertTrue(authenticationSchemes.isEmpty());
Set<String> schemas = config.getSchemas();
assertNotNull(schemas);
assertEquals(1, schemas.size());
assertTrue(schemas.contains(ServiceProviderConfig.SCHEMA));
}
}
@@ -0,0 +1,418 @@
package org.keycloak.tests.scim.tck;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.representations.userprofile.config.UPAttributeRequired;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.scim.client.ScimClient;
import org.keycloak.scim.client.ScimClientException;
import org.keycloak.scim.protocol.response.ErrorResponse;
import org.keycloak.scim.resource.common.Email;
import org.keycloak.scim.resource.common.Name;
import org.keycloak.scim.resource.user.EnterpriseUser;
import org.keycloak.scim.resource.user.EnterpriseUser.Manager;
import org.keycloak.scim.resource.user.User;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.ClientConfigBuilder;
import org.keycloak.testframework.realm.UserConfigBuilder;
import org.keycloak.testframework.scim.client.annotations.InjectScimClient;
import org.keycloak.testframework.util.ApiUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA;
import static org.keycloak.scim.model.user.AbstractUserModelSchema.ANNOTATION_SCIM_SCHEMA_ATTRIBUTE;
import static org.keycloak.scim.resource.Scim.ENTERPRISE_USER_SCHEMA;
import static org.keycloak.scim.resource.Scim.USER_RESOURCE_TYPE;
import static org.keycloak.scim.resource.Scim.getCoreSchema;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@KeycloakIntegrationTest(config = ScimServerConfig.class)
public class UserTest extends AbstractScimTest {
@InjectScimClient(clientId = "noaccess-scim-client", clientSecret = "secret", attachTo = "noaccess-scim-client")
ScimClient noAccessClient;
@BeforeEach
public void onBefore() {
UPConfig upConfig = realm.admin().users().userProfile().getConfiguration();
upConfig.getAttribute(UserModel.FIRST_NAME).setRequired(null);
upConfig.getAttribute(UserModel.LAST_NAME).setRequired(null);
upConfig.getAttribute(UserModel.EMAIL).setRequired(null);
Iterator<UPAttribute> iterator = upConfig.getAttributes().iterator();
while (iterator.hasNext()) {
UPAttribute attribute = iterator.next();
if (Set.of(UserModel.USERNAME, UserModel.FIRST_NAME, UserModel.LAST_NAME, UserModel.EMAIL).contains(attribute.getName())) {
continue;
}
iterator.remove();
}
realm.admin().users().userProfile().update(upConfig);
}
@Test
public void testCreateWithMinimalRepresentation() {
User expected = new User();
expected.setUserName(KeycloakModelUtils.generateId());
User actual = client.users().create(expected);
actual = client.users().get(actual.getId());
assertEquals(1, actual.getSchemas().size());
assertRootAttributes(actual, expected);
}
@Test
public void testCreateWithSingleEmail() {
User expected = new User();
expected.setUserName(KeycloakModelUtils.generateId());
expected.setEmail(expected.getEmail() + "@keycloak.org");
User actual = client.users().create(expected);
actual = client.users().get(actual.getId());
assertEquals(1, actual.getSchemas().size());
assertRootAttributes(actual, expected);
}
@Test
public void testCreateWithExternalId() {
UPConfig configuration = realm.admin().users().userProfile().getConfiguration();
configuration.addOrReplaceAttribute(new UPAttribute("myExternalId", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "externalId")));
realm.admin().users().userProfile().update(configuration);
User expected = new User();
expected.setUserName(KeycloakModelUtils.generateId());
expected.setExternalId(KeycloakModelUtils.generateId());
User actual = client.users().create(expected);
actual = client.users().get(actual.getId());
assertRootAttributes(actual, expected);
assertEquals(expected.getExternalId(), actual.getExternalId());
}
@Test
public void testCreateWithFullNameAttributes() {
UPConfig configuration = realm.admin().users().userProfile().getConfiguration();
configuration.addOrReplaceAttribute(new UPAttribute("middleName", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.middleName")));
configuration.addOrReplaceAttribute(new UPAttribute("honorificPrefix", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.honorificPrefix")));
configuration.addOrReplaceAttribute(new UPAttribute("honorificSuffix", Map.of(
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "name.honorificSuffix")));
realm.admin().users().userProfile().update(configuration);
User expected = new User();
expected.setUserName(KeycloakModelUtils.generateId());
Name name = new Name();
name.setGivenName("John");
name.setFamilyName("Doe");
name.setMiddleName("M");
name.setHonorificPrefix("Mr.");
name.setHonorificSuffix("Jr.");
expected.setName(name);
User actual = client.users().create(expected);
actual = client.users().get(actual.getId());
assertRootAttributes(actual, expected);
Name actualName = actual.getName();
assertNotNull(actualName);
assertEquals(name.getMiddleName(), actualName.getMiddleName());
assertEquals(name.getHonorificPrefix(), actualName.getHonorificPrefix());
assertEquals(name.getHonorificSuffix(), actualName.getHonorificSuffix());
assertEquals("Mr. John M Doe Jr.", actualName.getFormatted());
}
@Test
public void testLocale() {
RealmRepresentation realm = this.realm.admin().toRepresentation();
realm.setInternationalizationEnabled(true);
this.realm.admin().update(realm);
User expected = new User();
expected.setUserName(KeycloakModelUtils.generateId());
expected.setLocale("en");
User actual = client.users().create(expected);
actual = client.users().get(actual.getId());
assertRootAttributes(actual, expected);
assertNotNull(actual.getLocale());
assertEquals(expected.getLocale(), actual.getLocale());
}
@Test
public void testLocaleInternationalizationDisabled() {
User expected = new User();
expected.setUserName(KeycloakModelUtils.generateId());
expected.setLocale("en");
User actual = client.users().create(expected);
actual = client.users().get(actual.getId());
assertRootAttributes(actual, expected);
assertNull(actual.getLocale());
}
@Test
@Disabled
public void testDisplayName() {
// TODO: The displayName attribute is currently not supported by the SCIM User resource. We need to decide how to map it to the Keycloak user model and implement support for it.
// Accordingly to the specs, the displayName can map to the username, the name of the user, or some other attribute that represents "the primary textual
// label by which this User is normally displayed by the service provider when presenting it to end-users".
// That means that the value of the displayName can be derived from other attributes, but it can also be a separate attribute that can be set independently of the others.
// We probably need to support both scenarios by allowing to configure how the value should be derived by providing "resource type" settings.
}
@Test
public void testCreateEnterpriseUser() {
UPConfig configuration = realm.admin().users().userProfile().getConfiguration();
configuration.addOrReplaceAttribute(new UPAttribute("department", Map.of(
ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA,
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "department")));
configuration.addOrReplaceAttribute(new UPAttribute("division", Map.of(
ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA,
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "division")));
configuration.addOrReplaceAttribute(new UPAttribute("costCenter", Map.of(
ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA,
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "costCenter")));
configuration.addOrReplaceAttribute(new UPAttribute("employeeNumber", Map.of(
ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA,
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "employeeNumber")));
configuration.addOrReplaceAttribute(new UPAttribute("organization", Map.of(
ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA,
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "organization")));
configuration.addOrReplaceAttribute(new UPAttribute("manager", Map.of(
ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA,
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "manager.value")));
configuration.addOrReplaceAttribute(new UPAttribute("managerName", Map.of(
ANNOTATION_SCIM_SCHEMA, ENTERPRISE_USER_SCHEMA,
ANNOTATION_SCIM_SCHEMA_ATTRIBUTE, "manager.dispayName")));
realm.admin().users().userProfile().update(configuration);
User expected = createUser();
EnterpriseUser enterpriseUser = new EnterpriseUser();
enterpriseUser.setCostCenter("c");
enterpriseUser.setDepartment("dp");
enterpriseUser.setDivision("dv");
enterpriseUser.setOrganization("o");
enterpriseUser.setEmployeeNumber("en");
Manager manager = new Manager();
manager.setValue("m");
manager.setDisplayName("mdn");
enterpriseUser.setManager(manager);
expected.setEnterpriseUser(enterpriseUser);
User actual = client.users().create(expected);
actual = client.users().get(actual.getId());
assertEquals(2, actual.getSchemas().size());
assertRootAttributes(actual, expected);
assertNotNull(actual.getEnterpriseUser());
assertEquals(enterpriseUser.getDepartment(), actual.getEnterpriseUser().getDepartment());
assertEquals(enterpriseUser.getDivision(), actual.getEnterpriseUser().getDivision());
assertEquals(enterpriseUser.getCostCenter(), actual.getEnterpriseUser().getCostCenter());
assertEquals(enterpriseUser.getOrganization(), actual.getEnterpriseUser().getOrganization());
assertEquals(enterpriseUser.getEmployeeNumber(), actual.getEnterpriseUser().getEmployeeNumber());
}
@Test
public void testGetById() {
User expected = createUser();
String id = client.users().create(expected).getId();
User actual = client.users().get(id);
assertRootAttributes(actual, expected);
}
@Test
public void testGetExisting() {
UserRepresentation existing = UserConfigBuilder.create()
.username(KeycloakModelUtils.generateId())
.email(KeycloakModelUtils.generateId() + "@keycloak.org")
.firstName("f")
.lastName("l")
.enabled(true)
.build();
try (Response response = realm.admin().users().create(existing)) {
String id = ApiUtil.getCreatedId(response);
existing.setId(id);
}
User actual = client.users().get(existing.getId());
assertNotNull(actual);
assertEquals(existing.getUsername(), actual.getUserName());
assertEquals(existing.getEmail(), actual.getEmail());
assertEquals(existing.getFirstName(), actual.getFirstName());
assertEquals(existing.getLastName(), actual.getLastName());
assertEquals(existing.isEnabled(), actual.getActive());
}
@Test
public void testValidateUserProfileConfigOnCreate() {
UPConfig upConfig = realm.admin().users().userProfile().getConfiguration();
upConfig.getAttribute(UserModel.EMAIL).setRequired(new UPAttributeRequired());
realm.admin().users().userProfile().update(upConfig);
User expected = new User();
expected.setUserName(KeycloakModelUtils.generateId());
try {
client.users().create(expected);
fail("should fail because of required fields");
} catch (ScimClientException sce) {
ErrorResponse error = sce.getError();
assertNotNull(error);
assertEquals(400, error.getStatusInt());
assertEquals("Please specify email.", error.getDetail());
}
expected.setEmail(expected.getUserName() + "@keycloak.org");
client.users().create(expected);
}
@Test
public void testUpdate() {
User expected = client.users().create(createUser());
expected.setEmail(expected.getEmail().replace("keycloak.org", "updated.org"));
User actual = client.users().update(expected);
assertEquals(1, actual.getSchemas().size());
assertRootAttributes(actual, expected);
}
@Test
public void testValidateUserProfileOnUpdate() {
User expected = client.users().create(createUser());
expected.setEmail("invalid");
try {
client.users().update(expected);
} catch (ScimClientException sce) {
assertNotNull(sce.getError());
assertEquals(Status.BAD_REQUEST.getStatusCode(), sce.getError().getStatusInt());
assertEquals("Invalid email address.", sce.getError().getDetail());
}
}
@Test
public void testDelete() {
User expected = createUser();
String id = client.users().create(expected).getId();
User actual = client.users().get(id);
client.users().delete(actual.getId());
actual = client.users().get(id);
assertNull(actual);
}
@Test
public void testError() {
User user = client.users().create(createUser());
try {
client.users().create(user);
fail("should fail");
} catch (ScimClientException sce) {
ErrorResponse error = sce.getError();
assertNotNull(error);
assertEquals(400, error.getStatusInt());
assertNotNull(error.getDetail());
}
}
@Test
public void testNoManagePermission() {
realm.admin().clients().create(ClientConfigBuilder
.create()
.clientId("noaccess-scim-client")
.secret("secret")
.serviceAccountsEnabled(true)
.enabled(true)
.build()).close();
try {
noAccessClient.users().create(createUser());
} catch (ScimClientException sce) {
ErrorResponse error = sce.getError();
assertNotNull(error);
assertEquals(Status.FORBIDDEN.getStatusCode(), error.getStatusInt());
}
}
private void assertRootAttributes(User actual, User expected) {
assertNotNull(actual);
assertTrue(actual.hasSchema(getCoreSchema(expected.getClass())));
assertNotNull(actual.getMeta());
assertEquals(USER_RESOURCE_TYPE, actual.getMeta().getResourceType());
assertNotNull(actual.getMeta().getCreated());
assertNotNull(actual.getMeta().getLastModified());
assertNotNull(actual.getMeta().getLocation());
assertEquals(expected.getUserName(), actual.getUserName());
if (expected.getActive() != null) {
assertEquals(expected.getActive(), actual.getActive());
}
if (expected.getEmail() != null) {
assertNotNull(actual.getEmails());
assertEquals(expected.getEmails().size(), actual.getEmails().size());
for (Email email : expected.getEmails()) {
Email actualEmail = actual.getEmails().stream()
.filter((e) -> email.getValue().equals(e.getValue()))
.findFirst()
.orElse(null);
assertNotNull(actualEmail);
assertEquals(email.getType(), actualEmail.getType());
assertEquals(email.getPrimary(), actualEmail.getPrimary());
}
}
Name name = expected.getName();
if (name != null) {
assertEquals(name.getFamilyName(), actual.getName().getFamilyName());
assertEquals(name.getGivenName(), actual.getName().getGivenName());
// TODO: support for middleName, formatted, honorificPrefix, honorificSuffix
// assertEquals(name.getMiddleName(), actual.getName().getMiddleName());
// assertEquals(name.getFormatted(), actual.getName().getFormatted());
// assertEquals(name.getHonorificPrefix(), actual.getName().getHonorificPrefix());
// assertEquals(name.getHonorificSuffix(), actual.getName().getHonorificSuffix());
}
// assertEquals(expected.getNickName(), actual.getNickName());
}
private User createUser() {
User user = new User();
user.setUserName(KeycloakModelUtils.generateId());
user.setEmail(user.getUserName() + "@keycloak.org");
user.setExternalId(KeycloakModelUtils.generateId());
user.setActive(true);
Name name = new Name();
name.setGivenName(user.getUserName() + "_Given");
name.setMiddleName(user.getUserName() + "_Middle");
name.setFamilyName(user.getUserName() + "_Family");
name.setFormatted(name.getGivenName() + " " + name.getMiddleName() + " " + name.getFamilyName());
name.setHonorificPrefix("Mr.");
name.setHonorificSuffix("Jr.");
user.setName(name);
user.setNickName("mynickname");
return user;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-tests-parent</artifactId>
<groupId>org.keycloak.tests</groupId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../../tests/pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-scim-tests-parent</artifactId>
<name>Keycloak SCIM Base Testsuite Parent</name>
<packaging>pom</packaging>
<description>Keycloak SCIM Base Testsuite Parent</description>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-bom</artifactId>
<version>${project.version}</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
<modules>
<module>base</module>
<module>scim-client</module>
</modules>
</project>
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-scim-tests-parent</artifactId>
<groupId>org.keycloak.tests</groupId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-test-framework-scim-client</artifactId>
<name>Keycloak Test Framework - SCIM Server extension</name>
<packaging>jar</packaging>
<description>SCIM server extension for Keycloak Test Framework</description>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-scim-client</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-core</artifactId>
<scope>compile</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,15 @@
package org.keycloak.testframework.scim.client;
import java.util.List;
import org.keycloak.testframework.TestFrameworkExtension;
import org.keycloak.testframework.injection.Supplier;
public class SCIMTestFrameworkExtension implements TestFrameworkExtension {
@Override
public List<Supplier<?, ?>> suppliers() {
return List.of(new ScimClientSupplier());
}
}
@@ -0,0 +1,86 @@
package org.keycloak.testframework.scim.client;
import java.util.List;
import jakarta.ws.rs.core.Response;
import org.keycloak.admin.client.resource.RoleResource;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.Constants;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.scim.client.ScimClient;
import org.keycloak.scim.client.authorization.OAuth2Bearer;
import org.keycloak.testframework.injection.DependenciesBuilder;
import org.keycloak.testframework.injection.Dependency;
import org.keycloak.testframework.injection.InstanceContext;
import org.keycloak.testframework.injection.RequestedInstance;
import org.keycloak.testframework.injection.Supplier;
import org.keycloak.testframework.injection.SupplierOrder;
import org.keycloak.testframework.realm.ClientConfigBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.scim.client.annotations.InjectScimClient;
import org.keycloak.testframework.server.KeycloakServer;
import org.keycloak.testframework.util.ApiUtil;
import org.apache.http.client.HttpClient;
public class ScimClientSupplier implements Supplier<ScimClient, InjectScimClient>{
@Override
public ScimClient getValue(InstanceContext<ScimClient, InjectScimClient> instanceContext) {
HttpClient httpClient = instanceContext.getDependency(HttpClient.class);
KeycloakServer server = instanceContext.getDependency(KeycloakServer.class);
ManagedRealm managedRealm = instanceContext.getDependency(ManagedRealm.class);
InjectScimClient config = instanceContext.getAnnotation();
List<ClientRepresentation> scimClient = managedRealm.admin().clients().findByClientId(config.clientId());
if (scimClient.isEmpty() && config.attachTo().isEmpty()) {
try (Response response = managedRealm.admin().clients().create(ClientConfigBuilder.create()
.clientId(config.clientId())
.secret(config.clientSecret())
.serviceAccountsEnabled(true)
.enabled(true)
.build())) {
String id = ApiUtil.getCreatedId(response);
UserRepresentation serviceAccountUser = managedRealm.admin().clients().get(id).getServiceAccountUser();
ClientRepresentation realmMgmtClient = managedRealm.admin().clients().findByClientId(Constants.REALM_MANAGEMENT_CLIENT_ID).get(0);
RoleResource manageUsersRole = managedRealm.admin().clients().get(realmMgmtClient.getId()).roles().get(AdminRoles.MANAGE_USERS);
managedRealm.admin().users().get(serviceAccountUser.getId()).roles()
.clientLevel(realmMgmtClient.getId())
.add(List.of(manageUsersRole.toRepresentation()));
}
}
String serverBaseUrl = server.getBaseUrl();
String tokenEndpoint = serverBaseUrl + "/realms/" + managedRealm.getName() + "/protocol/openid-connect/token";
return ScimClient.create(httpClient)
.withBaseUrl(serverBaseUrl + "/realms/" + managedRealm.getName())
.withAuthorization(new OAuth2Bearer(tokenEndpoint, config.clientId(), config.clientSecret()))
.build();
}
@Override
public boolean compatible(InstanceContext<ScimClient, InjectScimClient> a, RequestedInstance<ScimClient, InjectScimClient> b) {
return a.getAnnotation().clientId().equals(b.getAnnotation().clientId());
}
@Override
public List<Dependency> getDependencies(RequestedInstance<ScimClient, InjectScimClient> instanceContext) {
return DependenciesBuilder.create(HttpClient.class).add(KeycloakServer.class).add(ManagedRealm.class).build();
}
@Override
public String getRef(InjectScimClient annotation) {
return annotation.clientId();
}
@Override
public void close(InstanceContext<ScimClient, InjectScimClient> instanceContext) {
}
@Override
public int order() {
return SupplierOrder.BEFORE_REALM;
}
}
@@ -0,0 +1,22 @@
package org.keycloak.testframework.scim.client.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectScimClient {
String clientId() default "scim-client";
String clientSecret() default "secret";
/**
* Attach to an existing client instead of creating one; when attaching to an existing client the config will be ignored
* and the client will not be deleted automatically.
*
* @return the client-id of the existing client to attach to
*/
String attachTo() default "";
}
@@ -0,0 +1 @@
org.keycloak.testframework.scim.client.SCIMTestFrameworkExtension