Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
da4595f
reorganized orgs into 2 implementations for keycloak and phasetwo org…
xgp Sep 5, 2025
db961f4
updated services file with correct class name
xgp Sep 6, 2025
1fd4eda
fix: Reduce logging during external token validation to minimize nois…
nicolamacoir Dec 17, 2025
fe65d8e
removing phasetwo specific implementation
xgp Feb 4, 2026
08ee4e6
merge upstream
xgp Feb 4, 2026
ee2cf1d
removing keycloak-orgs and phasetwo references to prepare for externa…
xgp Feb 4, 2026
b6d836c
abstracted out the scim config from org attributes into a full config…
xgp Feb 5, 2026
f78138e
feat: add support for custom user attributes via SCIM
Feb 24, 2026
65e5b86
test: add identity provider and mappers for custom user attributes
Feb 24, 2026
834abe7
docs: update README to reflect Microsoft Entra ID terminology and fix…
Feb 25, 2026
4fd68ca
feat: update user attribute source to include IDP_MAPPER and handle c…
Feb 26, 2026
e7ed908
Merge branch 'Metatavu:develop' into Feat/support-for-custom-user-att…
msegurar02 Feb 27, 2026
79dee4d
Merge branch 'Metatavu:develop' into Feat/support-for-custom-user-att…
msegurar02 Feb 27, 2026
60034d0
Merge branch 'Metatavu:develop' into Feat/support-for-custom-user-att…
msegurar02 Mar 10, 2026
f1a4c92
docs: update installation instructions in README.md for GitHub Release
nicolamacoir Mar 17, 2026
5816b29
Merge pull request #90 from nicolamacoir/develop
nicolamacoir Mar 18, 2026
f5451bb
Merge branch 'develop' into xgp/p2-orgs
xgp Mar 18, 2026
bde85a6
Merge branch 'develop' into fix/reduce-logging-during-external-token-…
nicolamacoir Apr 1, 2026
1f5aee8
feat: add debug logging for various operations across multiple classes
nicolamacoir Apr 1, 2026
a1ac844
fix: reduce debug logging during external token validation
nicolamacoir Apr 1, 2026
18570a6
fix: streamline debug logging in AdminEventController
nicolamacoir Apr 1, 2026
9c05373
Merge pull request #59 from nicolamacoir/fix/reduce-logging-during-ex…
nicolamacoir Apr 1, 2026
28f7efa
added basic auth for okta compatibility. updated readme.
xgp Apr 7, 2026
edc4477
Merge pull request #92 from xgp/xgp/auth
nicolamacoir Apr 13, 2026
ae06505
Merge remote-tracking branch 'upstream/develop' into xgp/p2-orgs
xgp Apr 16, 2026
14e406f
Merge remote-tracking branch 'metavu/develop' into Feat/support-for-c…
Apr 19, 2026
1be721a
Merge branch 'develop' into Feat/support-for-custom-user-attributes
msegurar02 Apr 19, 2026
865decb
fix wrong merge
Apr 19, 2026
6eddda3
address PR review: lazy provider init, remove organizationType and re…
xgp Apr 23, 2026
1871963
Merge pull request #47 from xgp/xgp/p2-orgs
nicolamacoir Apr 23, 2026
3bbe427
Handle path-less PatchOp shape
dnl555 Apr 23, 2026
4187655
Merge pull request #97 from dnl555/fix/path-less-patchop
nicolamacoir Apr 27, 2026
8852d33
fix: cleanup after wrong merge
Apr 29, 2026
199ce36
Handle path-less PatchOp on Groups + return valid JSON errors
dnl555 May 12, 2026
25ffea9
fix: guard null email in group membership leave admin event
nicolamacoir May 19, 2026
7a3af32
fix: retain omitted name subattributes on PUT /Users
nicolamacoir May 19, 2026
02a11e9
fix: surface duplicate-username and duplicate-email conflicts on POST…
nicolamacoir May 19, 2026
5c89498
Merge pull request #104 from nicolamacoir/fix/group-leave-event-null-…
nicolamacoir May 22, 2026
a34e3e0
Build SCIM Error JSON via Jackson so detail is safely escaped
dnl555 May 25, 2026
0e76eda
Inline RealmScimServer.scimError shim through ScimErrors directly
dnl555 May 25, 2026
b39aa18
Share applyPatchValue between realm + org controllers; fix REMOVE NPE…
dnl555 May 25, 2026
5d7adc0
Unify group MEMBERS handling, resolve atomically, reject non-String d…
dnl555 May 25, 2026
673fac3
Assert SCIM Error status + body structure in list-filter tests
dnl555 May 25, 2026
c966217
Cover path-less Group PatchOp add-member with an integration test
dnl555 May 25, 2026
25f0828
Merge remote-tracking branch 'origin/develop' into fix/pr-101-review
dnl555 May 25, 2026
0cca738
Extract seed + assert helpers in group-patch atomic tests
dnl555 May 25, 2026
936a543
Extract applyPatchOperations to share PATCH loop between realm + org …
dnl555 May 25, 2026
519adc7
feat: enhance user attribute handling with improved error logging and…
May 27, 2026
c4517f7
Merge pull request #81 from msegurar02/Feat/support-for-custom-user-a…
msegurar02 May 27, 2026
79b5a37
Merge pull request #101 from dnl555/fix/path-less-patchop-on-groups
nicolamacoir May 28, 2026
bad2c07
Merge branch 'develop' into fix/duplicate-user-create-errors
nicolamacoir May 28, 2026
9666248
fix: use ScimErrors.conflict for duplicate user checks in Organizatio…
nicolamacoir May 28, 2026
a27196d
fix: add missing assertTrue import in RealmUserCreateTestsIT
nicolamacoir May 28, 2026
884e15e
Merge pull request #110 from nicolamacoir/fix/duplicate-user-create-e…
nicolamacoir Jun 1, 2026
e4d0772
Merge branch 'develop' into fix/put-user-omitted-name-subattributes
nicolamacoir Jun 1, 2026
c27c3ab
Merge pull request #106 from nicolamacoir/fix/put-user-omitted-name-s…
nicolamacoir Jun 2, 2026
ce1f0e4
test: cover ADD members atomicity with mixed valid/unknown IDs
nicolamacoir Jun 2, 2026
b8cb96e
Merge pull request #108 from nicolamacoir/fix/group-patch-invalid-mem…
nicolamacoir Jun 2, 2026
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
581 changes: 478 additions & 103 deletions README.md

Large diffs are not rendered by default.

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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import fi.metatavu.keycloak.scim.server.groups.GroupsController;
import fi.metatavu.keycloak.scim.server.metadata.MetadataController;
import fi.metatavu.keycloak.scim.server.users.UsersController;
import java.util.Base64;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import jakarta.ws.rs.ForbiddenException;
Expand Down Expand Up @@ -105,8 +106,36 @@ public void verifyPermissions(T scimContext) {

if (config.getAuthenticationMode() == ScimConfig.AuthenticationMode.KEYCLOAK) {
keycloakAuthentication(context, session, realm, headers);
} else if (authorization.startsWith("Basic ")) {
basicAuthentication(config, authorization, session);
} else {
externalAuthentication(config, extractToken(authorization), session);
externalAuthentication(config, extractBearerToken(authorization), session);
}
}

private void basicAuthentication(ScimConfig config, String authorization, KeycloakSession session) {
String basicAuthUsername = config.getBasicAuthUsername();
String basicAuthPassword = config.getBasicAuthPassword();

if (basicAuthUsername == null || basicAuthUsername.isBlank() || basicAuthPassword == null || basicAuthPassword.isBlank()) {
logger.warn("Basic auth credentials received but Basic auth is not configured");
throw new NotAuthorizedException("Basic auth is not configured");
}

String encoded = authorization.substring("Basic ".length()).trim();
String decoded;
try {
decoded = new String(Base64.getDecoder().decode(encoded));
} catch (IllegalArgumentException e) {
logger.warn("Invalid Base64 in Basic auth header");
throw new NotAuthorizedException("Invalid Basic auth header");
}

Verifier verifier = VerifierFactory.buildBasicAuth(config, session);

if (!verifier.verify(decoded)) {
logger.warn("Basic auth verification failed");
throw new NotAuthorizedException("Basic auth verification failed");
}
}

Expand Down Expand Up @@ -153,7 +182,7 @@ private void keycloakAuthentication(KeycloakContext context, KeycloakSession ses
}
}

private String extractToken(String authorization) {
private String extractBearerToken(String authorization) {
if (authorization.startsWith("Bearer ")) {
return authorization.substring("Bearer ".length()).trim();
} else {
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
@@ -1,15 +1,22 @@
package fi.metatavu.keycloak.scim.server;

import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.models.KeycloakSession;

/**
* SCIM realm resource provider
*/
public class ScimRealmResourceProvider implements RealmResourceProvider {

private final KeycloakSession session;

public ScimRealmResourceProvider(KeycloakSession session) {
this.session = session;
}

@Override
public Object getResource() {
return new ScimResources();
return new ScimResources(session);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
import org.jboss.logging.Logger;

/**
* SCIM realm resource provider factory
Expand All @@ -13,13 +14,16 @@
*/
public class ScimRealmResourceProviderFactory implements RealmResourceProviderFactory {

private static final Logger logger = Logger.getLogger(ScimRealmResourceProviderFactory.class);

@Override
public RealmResourceProvider create(KeycloakSession session) {
return new ScimRealmResourceProvider();
return new ScimRealmResourceProvider(session);
}

@Override
public void init(Config.Scope config) {}
public void init(Config.Scope config) {
}

@Override
public void postInit(KeycloakSessionFactory factory) {}
Expand Down
Loading