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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,36 @@ protected fi.metatavu.keycloak.scim.server.model.Meta getMeta(
return result;
}

/**
* Whether the given SCIM attribute path refers to a read-only or
* structural core attribute that PATCH must ignore per RFC 7644 §3.5.2
* (and the SCIM core schema, RFC 7643 §3.1).
*
* Concretely: "id" (server-assigned resource identifier), "meta"
* (server-assigned metadata), and "schemas" (structural). Servers MUST
* not error on these in PATCH payloads; clients (notably Okta on Group
* Push and user provisioning) echo them back when a PATCH value is
* constructed from a prior GET. Resource controllers consult this
* before resolving an attribute and silently skip the operation when
* it matches.
*
* Note: "externalId" is intentionally NOT in this list. Per RFC 7643
* §3.1 externalId is an OPTIONAL, client-settable attribute and a
* legitimate target of PATCH.
*
* @param attrPath attribute path from a PatchOp (path-less or path-based)
* @return true if the attribute is read-only / structural and must be ignored
*/
protected static boolean isReadOnlyOrStructural(String attrPath) {
if (attrPath == null) {
return false;
}
return switch (attrPath) {
case "id", "meta", "schemas" -> true;
default -> false;
};
}

/**
* Returns date based on year, month and date
*
Expand Down
83 changes: 83 additions & 0 deletions src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package fi.metatavu.keycloak.scim.server;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.ws.rs.core.Response;

/**
* Helper for building SCIM 2.0 Error responses (RFC 7644 §3.12).
*
* Existing call sites returned plain-text bodies (e.g. "Unsupported group path",
* "Missing userName"), which broke clients that strictly parse error responses
* as JSON (Okta, Entra ID). All error responses go through this helper now and
* return a valid SCIM Error JSON document with the application/scim+json media
* type.
*
* The body is built via Jackson so any control character or quote that arrives
* in {@code detail} (typically from a user-supplied attribute path interpolated
* into an error message) is escaped correctly. A hand-rolled escape used to
* cover only `\\` and `"` and reintroduced the JSON parse failure on the client
* side as soon as a `\\n` or `\\t` reached `detail`.
*/
public final class ScimErrors {

private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";

private ScimErrors() {
// utility class
}

/**
* Build a SCIM 2.0 Error response.
*
* @param status HTTP status (e.g. BAD_REQUEST)
* @param detail human-readable error detail; null is rendered as the empty string
* @return Response carrying a SCIM Error JSON body and application/scim+json type
*/
public static Response error(Response.Status status, String detail) {
ObjectNode node = MAPPER.createObjectNode();
node.putArray("schemas").add(ERROR_SCHEMA);
node.put("status", Integer.toString(status.getStatusCode()));
node.put("detail", detail == null ? "" : detail);
String body;
try {
body = MAPPER.writeValueAsString(node);
} catch (JsonProcessingException e) {
// ObjectNode is always serializable; fall back to a static body if Jackson
// somehow fails so we still return SCIM-shaped JSON.
body = "{\"schemas\":[\"" + ERROR_SCHEMA + "\"],\"status\":\""
+ status.getStatusCode() + "\",\"detail\":\"\"}";
}
return Response.status(status).type("application/scim+json").entity(body).build();
}

/**
* Convenience for HTTP 400 errors.
*/
public static Response badRequest(String detail) {
return error(Response.Status.BAD_REQUEST, detail);
}

/**
* Convenience for HTTP 404 errors.
*/
public static Response notFound(String detail) {
return error(Response.Status.NOT_FOUND, detail);
}

/**
* Convenience for HTTP 409 errors.
*/
public static Response conflict(String detail) {
return error(Response.Status.CONFLICT, detail);
}

/**
* Convenience for HTTP 403 errors.
*/
public static Response forbidden(String detail) {
return error(Response.Status.FORBIDDEN, detail);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public Response listRealmUsers(
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 ScimErrors.badRequest("Invalid filter");
}

return realmScimServer.listUsers(
Expand Down Expand Up @@ -213,7 +213,7 @@ public Response listRealmGroups(
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 ScimErrors.badRequest("Invalid filter");
}

return realmScimServer.listGroups(
Expand Down Expand Up @@ -424,7 +424,7 @@ public Response listOrganizationUsers(
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 ScimErrors.badRequest("Invalid filter");
}

return getOrganizationScimServer().listUsers(
Expand Down Expand Up @@ -554,7 +554,7 @@ public Response listOrganizationGroups(
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 ScimErrors.badRequest("Invalid filter");
}

return getOrganizationScimServer().listGroups(
Expand Down
Loading
Loading