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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package fi.metatavu.keycloak.scim.server;

import jakarta.ws.rs.core.Response;

public final class ScimErrorResponse {

private static final String SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";

private ScimErrorResponse() {}

public static Response scimError(Response.Status status, String scimType, String detail) {
String json = "{\"schemas\":[\"" + SCHEMA + "\"],"
+ "\"status\":\"" + status.getStatusCode() + "\""
+ (scimType != null ? ",\"scimType\":\"" + scimType + "\"" : "")
+ ",\"detail\":\"" + detail.replace("\\", "\\\\").replace("\"", "\\\"") + "\"}";
return Response.status(status).entity(json).type("application/scim+json").build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
scimFilter = parseFilter(filter);
} catch (Exception e) {
logger.warn(String.format("Failed to parse filter: '%s'", filter), e);
return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build();
return scimError(Response.Status.BAD_REQUEST, "invalidFilter", "Invalid filter");

Check failure on line 92 in src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "invalidFilter" 4 times.

See more on https://sonarcloud.io/project/issues?id=Metatavu_keycloak-scim-server&issues=AZ5fwP9kdgK5_2HT5Qxy&open=AZ5fwP9kdgK5_2HT5Qxy&pullRequest=114
}

return realmScimServer.listUsers(
Expand Down Expand Up @@ -213,7 +213,7 @@
scimFilter = parseFilter(filter);
} catch (Exception e) {
logger.warn(String.format("Failed to parse filter: '%s'", filter), e);
return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build();
return scimError(Response.Status.BAD_REQUEST, "invalidFilter", "Invalid filter");
}

return realmScimServer.listGroups(
Expand Down Expand Up @@ -424,7 +424,7 @@
scimFilter = parseFilter(filter);
} catch (Exception e) {
logger.warn(String.format("Failed to parse filter: '%s'", filter), e);
return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build();
return scimError(Response.Status.BAD_REQUEST, "invalidFilter", "Invalid filter");
}

return getOrganizationScimServer().listUsers(
Expand Down Expand Up @@ -554,7 +554,7 @@
scimFilter = parseFilter(filter);
} catch (Exception e) {
logger.warn(String.format("Failed to parse filter: '%s'", filter), e);
return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build();
return scimError(Response.Status.BAD_REQUEST, "invalidFilter", "Invalid filter");
}

return getOrganizationScimServer().listGroups(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.cache.UserCache;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;

import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

/**
Expand Down Expand Up @@ -140,8 +143,60 @@
* @param group SCIM group
* @return updated group
*/
public Group updateGroup(ScimContext scimContext, GroupModel existing, fi.metatavu.keycloak.scim.server.model.Group group) {

Check failure on line 146 in src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 23 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Metatavu_keycloak-scim-server&issues=AZ5frUKJWz5URAcnRC2v&open=AZ5frUKJWz5URAcnRC2v&pullRequest=114
existing.setName(group.getDisplayName());
KeycloakSession session = scimContext.getSession();
RealmModel realm = scimContext.getRealm();

if (group.getDisplayName() != null) {
existing.setName(group.getDisplayName());
}

// SCIM PUT is a full replace of the resource — reconcile members against the request.
// Okta's Group Push uses PUT (not PATCH) with the desired final member list.
List<GroupMembersInner> requestedMembers = group.getMembers();
if (requestedMembers != null) {
Set<String> desiredIds = requestedMembers.stream()
.map(GroupMembersInner::getValue)
.filter(Objects::nonNull)
.collect(Collectors.toSet());

List<UserModel> currentMembers = session.users()
.getGroupMembersStream(realm, existing)
.collect(Collectors.toList());

Set<String> currentIds = currentMembers.stream()
.map(UserModel::getId)
.collect(Collectors.toSet());

UserCache userCache = session.getProvider(UserCache.class);

// Remove members no longer in the desired set
for (UserModel user : currentMembers) {
if (!desiredIds.contains(user.getId())) {
user.leaveGroup(existing);
if (userCache != null) {
userCache.evict(realm, user);
}
dispatchGroupMembershipLeaveEvent(scimContext, existing, user);
}
}

// Add members that are new
for (String id : desiredIds) {
if (currentIds.contains(id)) {
continue;
}
UserModel user = session.users().getUserById(realm, id);
if (user != null) {
user.joinGroup(existing);
if (userCache != null) {
userCache.evict(realm, user);
}
dispatchGroupMembershipJoinEvent(scimContext, existing, user);
}
}
}

return translateGroup(scimContext, existing);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import fi.metatavu.keycloak.scim.server.patch.UnsupportedPatchOperation;
import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.NotFoundException;
import fi.metatavu.keycloak.scim.server.ScimErrorResponse;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import org.keycloak.models.*;
Expand All @@ -36,12 +37,12 @@

if (isBlank(createRequest.getUserName())) {
logger.warn("Cannot create user: Missing userName");
return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, "invalidValue", "Missing userName");

Check failure on line 40 in src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "invalidValue" 5 times.

See more on https://sonarcloud.io/project/issues?id=Metatavu_keycloak-scim-server&issues=AZ5fwP5cdgK5_2HT5Qxv&open=AZ5fwP5cdgK5_2HT5Qxv&pullRequest=114

Check failure on line 40 in src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Missing userName" 3 times.

See more on https://sonarcloud.io/project/issues?id=Metatavu_keycloak-scim-server&issues=AZ5fwP5cdgK5_2HT5Qxu&open=AZ5fwP5cdgK5_2HT5Qxu&pullRequest=114
}

if (emailAsUsername && !isValidEmail(createRequest.getUserName())) {
logger.warn("Cannot create user: Invalid email format for userName");
return Response.status(Response.Status.BAD_REQUEST).entity("Invalid email format for userName").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, "invalidValue", "Invalid email format for userName");
}

UserAttributes userAttributes = metadataController.getUserAttributes(scimContext);
Expand All @@ -68,19 +69,19 @@

if (isBlank(username)) {
logger.warn("Missing userName");
return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, "invalidValue", "Missing userName");
}

if (emailAsUsername && !isValidEmail(updateRequest.getUserName())) {
logger.warn("Cannot update user: Invalid email format for userName");
return Response.status(Response.Status.BAD_REQUEST).entity("Invalid email format for userName").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, "invalidValue", "Invalid email format for userName");
}

if (emailAsUsername && updateRequest.getEmails() != null) {
for (fi.metatavu.keycloak.scim.server.model.UserEmailsInner email : updateRequest.getEmails()) {
if (!Objects.equals(email.getValue(), updateRequest.getUserName())) {
logger.warn("Conflicting email and userName when emailAsUsername is enabled");
return Response.status(Response.Status.BAD_REQUEST).entity("Username and email must match when emailAsUsername is enabled").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, "invalidValue", "Username and email must match when emailAsUsername is enabled");
}
}
}
Expand All @@ -89,7 +90,7 @@
UserModel user = session.users().getUserById(realm, userId);
if (user == null) {
logger.warn(String.format("User not found: %s", userId));
return Response.status(Response.Status.NOT_FOUND).entity("User not found").build();
return ScimErrorResponse.scimError(Response.Status.NOT_FOUND, null, "User not found");
}

// Check if username is being changed to an already existing one
Expand All @@ -102,7 +103,7 @@

if (existing != null && !existing.getId().equals(userId)) {
logger.warn(String.format("User name already taken: %s", updateRequest.getUserName()));
return Response.status(Response.Status.CONFLICT).entity("User name already taken").build();
return ScimErrorResponse.scimError(Response.Status.CONFLICT, "uniqueness", "User name already taken");
}

UserAttributes userAttributes = metadataController.getUserAttributes(scimContext);
Expand All @@ -119,7 +120,7 @@
UserModel existing = session.users().getUserById(realm, userId);
if (existing == null) {
logger.warn(String.format("User not found: %s", userId));
return Response.status(Response.Status.NOT_FOUND).entity("User not found").build();
return ScimErrorResponse.scimError(Response.Status.NOT_FOUND, null, "User not found");
}

UserAttributes userAttributes = metadataController.getUserAttributes(scimContext);
Expand All @@ -128,7 +129,7 @@
fi.metatavu.keycloak.scim.server.model.User result = organizationUserController.patchOrganizationUser(scimContext, userAttributes, existing, patchRequest);
return Response.ok(result).build();
} catch (UnsupportedPatchOperation e) {
return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported patch operation").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, null, "Unsupported patch operation");
}
}

Expand Down Expand Up @@ -173,7 +174,7 @@
RoleModel scimManagedRole = realm.getRole("scim-managed");
if (scimManagedRole != null && !user.hasRole(scimManagedRole)) {
logger.warn(String.format("User is not SCIM-managed: %s", userId));
return Response.status(Response.Status.FORBIDDEN).entity("User is not managed by SCIM").build();
return ScimErrorResponse.scimError(Response.Status.FORBIDDEN, null, "User is not managed by SCIM");
}

organizationUserController.deleteOrganizationUser(scimContext, user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import fi.metatavu.keycloak.scim.server.patch.UnsupportedPatchOperation;
import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.NotFoundException;
import fi.metatavu.keycloak.scim.server.ScimErrorResponse;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import java.net.URI;
Expand Down Expand Up @@ -37,16 +38,17 @@

if (isBlank(createRequest.getUserName())) {
logger.warn("Cannot create user: Missing userName");
return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, "invalidValue", "Missing userName");

Check failure on line 41 in src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Missing userName" 3 times.

See more on https://sonarcloud.io/project/issues?id=Metatavu_keycloak-scim-server&issues=AZ5fwP9AdgK5_2HT5Qxx&open=AZ5fwP9AdgK5_2HT5Qxx&pullRequest=114

Check failure on line 41 in src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "invalidValue" 5 times.

See more on https://sonarcloud.io/project/issues?id=Metatavu_keycloak-scim-server&issues=AZ5fwP9AdgK5_2HT5Qxw&open=AZ5fwP9AdgK5_2HT5Qxw&pullRequest=114
}

UserAttributes userAttributes = metadataController.getUserAttributes(scimContext);

UserModel existing = session.users().getUserByUsername(realm, createRequest.getUserName());
if (existing != null) {
return Response.status(Response.Status.CONFLICT).entity("User already exists").build();
User updated = usersController.updateUser(scimContext, userAttributes, existing, createRequest);
return Response.ok(updated).build();
}

UserAttributes userAttributes = metadataController.getUserAttributes(scimContext);

User user = usersController.createUser(
scimContext,
userAttributes,
Expand All @@ -68,19 +70,19 @@

if (isBlank(updateRequest.getUserName())) {
logger.warn("Missing userName");
return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, "invalidValue", "Missing userName");
}

if (emailAsUsername && !isValidEmail(updateRequest.getUserName())) {
logger.warn("Cannot update user: Invalid email format for userName");
return Response.status(Response.Status.BAD_REQUEST).entity("Invalid email format for userName").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, "invalidValue", "Invalid email format for userName");
}

if (emailAsUsername && updateRequest.getEmails() != null) {
for (fi.metatavu.keycloak.scim.server.model.UserEmailsInner email : updateRequest.getEmails()) {
if (!Objects.equals(email.getValue(), updateRequest.getUserName())) {
logger.warn("Conflicting email and userName when emailAsUsername is enabled");
return Response.status(Response.Status.BAD_REQUEST).entity("Username and email must match when emailAsUsername is enabled").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, "invalidValue", "Username and email must match when emailAsUsername is enabled");
}
}
}
Expand All @@ -89,7 +91,7 @@
UserModel user = session.users().getUserById(realm, userId);
if (user == null) {
logger.warn(String.format("User not found: %s", userId));
return Response.status(Response.Status.NOT_FOUND).entity("User not found").build();
return ScimErrorResponse.scimError(Response.Status.NOT_FOUND, null, "User not found");
}

// Check if username is being changed to an already existing one
Expand All @@ -102,7 +104,7 @@

if (existing != null && !existing.getId().equals(userId)) {
logger.warn(String.format("User name already taken: %s", updateRequest.getUserName()));
return Response.status(Response.Status.CONFLICT).entity("User name already taken").build();
return ScimErrorResponse.scimError(Response.Status.CONFLICT, "uniqueness", "User name already taken");
}

UserAttributes userAttributes = metadataController.getUserAttributes(scimContext);
Expand All @@ -119,7 +121,7 @@
UserModel existing = session.users().getUserById(realm, userId);
if (existing == null) {
logger.warn(String.format("User not found: %s", userId));
return Response.status(Response.Status.NOT_FOUND).entity("User not found").build();
return ScimErrorResponse.scimError(Response.Status.NOT_FOUND, null, "User not found");
}

UserAttributes userAttributes = metadataController.getUserAttributes(scimContext);
Expand All @@ -128,7 +130,7 @@
fi.metatavu.keycloak.scim.server.model.User result = usersController.patchUser(scimContext, userAttributes, existing, patchRequest);
return Response.ok(result).build();
} catch (UnsupportedPatchOperation e) {
return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported patch operation").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, null, "Unsupported patch operation");
}
}

Expand Down Expand Up @@ -173,7 +175,7 @@
RoleModel scimManagedRole = realm.getRole("scim-managed");
if (scimManagedRole != null && !user.hasRole(scimManagedRole)) {
logger.warn(String.format("User is not SCIM-managed: %s", userId));
return Response.status(Response.Status.FORBIDDEN).entity("User is not managed by SCIM").build();
return ScimErrorResponse.scimError(Response.Status.FORBIDDEN, null, "User is not managed by SCIM");
}

usersController.deleteUser(scimContext, user);
Expand All @@ -187,7 +189,7 @@

if (isBlank(createRequest.getDisplayName())) {
logger.warn("Cannot create group: Missing displayName");
return Response.status(Response.Status.BAD_REQUEST).entity("Missing displayName").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, "invalidValue", "Missing displayName");
}

fi.metatavu.keycloak.scim.server.model.Group created = groupsController.createGroup(scimContext, createRequest);
Expand Down Expand Up @@ -225,7 +227,7 @@
}

if (!id.equals(existing.getId())) {
return Response.status(Response.Status.BAD_REQUEST).entity("Group ID mismatch").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, null, "Group ID mismatch");
}

fi.metatavu.keycloak.scim.server.model.Group updated = groupsController.updateGroup(
Expand All @@ -247,16 +249,16 @@
}

if (!groupId.equals(existing.getId())) {
return Response.status(Response.Status.BAD_REQUEST).entity("Group ID mismatch").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, null, "Group ID mismatch");
}

try {
fi.metatavu.keycloak.scim.server.model.Group updated = groupsController.patchGroup(scimContext, existing, patchRequest);
return Response.ok(updated).build();
} catch (UnsupportedGroupPath e) {
return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported group path").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, null, "Unsupported group path");
} catch (UnsupportedPatchOperation e) {
return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported patch operation").build();
return ScimErrorResponse.scimError(Response.Status.BAD_REQUEST, null, "Unsupported patch operation");
}
}

Expand Down