SCIM Provisioning in Java: Complete System-to-System User Management

SCIM (System for Cross-domain Identity Management) is a standardized protocol for automating user provisioning and identity management between systems. This guide provides a comprehensive implementation of SCIM 2.0 in Java.


SCIM 2.0 Overview

Key Concepts:

  • Resources: Users, Groups, etc.
  • Operations: Create, Read, Update, Delete (CRUD)
  • Schemas: Standardized attribute definitions
  • Bulk Operations: Multiple operations in one request

SCIM Endpoints:

  • /scim/v2/Users - User management
  • /scim/v2/Groups - Group management
  • /scim/v2/ServiceProviderConfig - Server capabilities
  • /scim/v2/ResourceTypes - Resource definitions
  • /scim/v2/Schemas - Schema definitions

Dependencies and Setup

Maven Dependencies
<properties> <spring-boot.version>3.1.0</spring-boot.version> <jackson.version>2.15.2</jackson.version> <scim-sdk.version>2.2.8</scim-sdk.version> </properties> <dependencies> <!-- Spring Boot --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> <version>${spring-boot.version}</version> </dependency> <!-- SCIM SDK --> <dependency> <groupId>com.unboundid.product.scim2</groupId> <artifactId>scim2-sdk-client</artifactId> <version>${scim-sdk.version}</version> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>${jackson.version}</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-core</artifactId> <version>${jackson.version}</version> </dependency> <!-- Database --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> </dependencies>
Application Configuration
# application.yml scim: base-url: https://api.example.com/scim/v2 default-page-size: 100 max-page-size: 1000 bulk: max-operations: 1000 max-payload-size: 1048576 spring: datasource: url: jdbc:postgresql://localhost:5432/scim_db username: scim_user password: scim_password jpa: hibernate: ddl-auto: update properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true server: servlet: context-path: /scim/v2

Domain Models

1. SCIM User Entity
@Entity @Table(name = "scim_users") @Data @NoArgsConstructor @AllArgsConstructor @Builder public class ScimUser { @Id private String id; @Column(nullable = false, unique = true) private String externalId; @Column(nullable = false) private String userName; @Column(nullable = false) private String displayName; private String title; private String preferredLanguage; private String locale; private String timezone; @Column(nullable = false) private Boolean active = true; @Column(nullable = false) private Boolean locked = false; @Embedded private Name name; @ElementCollection @CollectionTable(name = "scim_user_emails", joinColumns = @JoinColumn(name = "user_id")) private List<Email> emails; @ElementCollection @CollectionTable(name = "scim_user_phone_numbers", joinColumns = @JoinColumn(name = "user_id")) private List<PhoneNumber> phoneNumbers; @ElementCollection @CollectionTable(name = "scim_user_photos", joinColumns = @JoinColumn(name = "user_id")) private List<Photo> photos; @ElementCollection @CollectionTable(name = "scim_user_roles", joinColumns = @JoinColumn(name = "user_id")) private List<UserRole> roles; @ElementCollection @CollectionTable(name = "scim_user_entitlements", joinColumns = @JoinColumn(name = "user_id")) private List<Entitlement> entitlements; @ElementCollection @CollectionTable(name = "scim_user_x509_certificates", joinColumns = @JoinColumn(name = "user_id")) private List<X509Certificate> x509Certificates; @Column(nullable = false) private Instant metaCreated; @Column(nullable = false) private Instant metaLastModified; private String metaLocation; private String metaVersion; @Embeddable @Data @NoArgsConstructor @AllArgsConstructor @Builder public static class Name { private String formatted; private String familyName; private String givenName; private String middleName; private String honorificPrefix; private String honorificSuffix; } @Embeddable @Data @NoArgsConstructor @AllArgsConstructor @Builder public static class Email { private String value; private String type; // work, home, other private Boolean primary = false; } @Embeddable @Data @NoArgsConstructor @AllArgsConstructor @Builder public static class PhoneNumber { private String value; private String type; // work, home, mobile, fax, pager, other } @Embeddable @Data @NoArgsConstructor @AllArgsConstructor @Builder public static class Photo { private String value; private String type; // photo, thumbnail } @Embeddable @Data @NoArgsConstructor @AllArgsConstructor @Builder public static class UserRole { private String value; private String display; private String type; private Boolean primary = false; } @Embeddable @Data @NoArgsConstructor @AllArgsConstructor @Builder public static class Entitlement { private String value; private String display; private String type; private Boolean primary = false; } @Embeddable @Data @NoArgsConstructor @AllArgsConstructor @Builder public static class X509Certificate { private String value; } }
2. SCIM Group Entity
@Entity @Table(name = "scim_groups") @Data @NoArgsConstructor @AllArgsConstructor @Builder public class ScimGroup { @Id private String id; @Column(nullable = false, unique = true) private String externalId; @Column(nullable = false) private String displayName; private String description; @ManyToMany @JoinTable( name = "scim_group_members", joinColumns = @JoinColumn(name = "group_id"), inverseJoinColumns = @JoinColumn(name = "user_id") ) private List<ScimUser> members; @Column(nullable = false) private Instant metaCreated; @Column(nullable = false) private Instant metaLastModified; private String metaLocation; private String metaVersion; }
3. SCIM Configuration
@Entity @Table(name = "scim_configurations") @Data @NoArgsConstructor @AllArgsConstructor @Builder public class ScimConfiguration { @Id private String id; @Column(nullable = false) private String tenantId; private Integer maxResults; private Integer maxPayloadSize; private Boolean patchSupported; private Boolean bulkSupported; private Boolean filterSupported; private Boolean etagSupported; private Boolean sortSupported; private Boolean changePasswordSupported; @ElementCollection @CollectionTable(name = "scim_auth_schemes", joinColumns = @JoinColumn(name = "config_id")) private List<AuthenticationScheme> authenticationSchemes; @Embeddable @Data @NoArgsConstructor @AllArgsConstructor @Builder public static class AuthenticationScheme { private String type; private String name; private String description; private String specUri; private String documentationUri; } }

SCIM Service Layer

1. User Service
@Service @Transactional @Slf4j public class ScimUserService { private final ScimUserRepository userRepository; private final ScimGroupRepository groupRepository; private final ScimMapper scimMapper; public ScimUserService(ScimUserRepository userRepository, ScimGroupRepository groupRepository, ScimMapper scimMapper) { this.userRepository = userRepository; this.groupRepository = groupRepository; this.scimMapper = scimMapper; } public ScimUser createUser(ScimUser user) { log.info("Creating SCIM user: {}", user.getUserName()); user.setId(UUID.randomUUID().toString()); user.setMetaCreated(Instant.now()); user.setMetaLastModified(Instant.now()); // Set primary email if not specified if (user.getEmails() != null && !user.getEmails().isEmpty()) { boolean hasPrimary = user.getEmails().stream() .anyMatch(ScimUser.Email::getPrimary); if (!hasPrimary) { user.getEmails().get(0).setPrimary(true); } } ScimUser savedUser = userRepository.save(user); log.info("Created SCIM user with ID: {}", savedUser.getId()); return savedUser; } public Optional<ScimUser> getUserById(String id) { return userRepository.findById(id); } public Optional<ScimUser> getUserByUserName(String userName) { return userRepository.findByUserName(userName); } public List<ScimUser> getUsers(ScimFilter filter, int startIndex, int count) { log.debug("Fetching users with filter: {}, startIndex: {}, count: {}", filter, startIndex, count); // Apply filtering and pagination List<ScimUser> allUsers = userRepository.findAll(); return allUsers.stream() .filter(user -> matchesFilter(user, filter)) .skip(startIndex - 1) // SCIM uses 1-based indexing .limit(count) .collect(Collectors.toList()); } public ScimUser updateUser(String id, ScimUser updatedUser) { log.info("Updating SCIM user: {}", id); ScimUser existingUser = userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("User not found: " + id)); // Update fields scimMapper.updateUserFromUser(updatedUser, existingUser); existingUser.setMetaLastModified(Instant.now()); ScimUser savedUser = userRepository.save(existingUser); log.info("Updated SCIM user: {}", id); return savedUser; } public ScimUser patchUser(String id, ScimPatchOperation patchOperation) { log.info("Patching SCIM user: {}", id); ScimUser existingUser = userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("User not found: " + id)); applyPatchOperation(existingUser, patchOperation); existingUser.setMetaLastModified(Instant.now()); ScimUser savedUser = userRepository.save(existingUser); log.info("Patched SCIM user: {}", id); return savedUser; } public void deleteUser(String id) { log.info("Deleting SCIM user: {}", id); if (!userRepository.existsById(id)) { throw new UserNotFoundException("User not found: " + id); } // Remove user from all groups List<ScimGroup> groupsWithUser = groupRepository.findByMembersId(id); for (ScimGroup group : groupsWithUser) { group.getMembers().removeIf(member -> member.getId().equals(id)); groupRepository.save(group); } userRepository.deleteById(id); log.info("Deleted SCIM user: {}", id); } public boolean userExists(String userName) { return userRepository.findByUserName(userName).isPresent(); } public long getUserCount(ScimFilter filter) { List<ScimUser> allUsers = userRepository.findAll(); return allUsers.stream() .filter(user -> matchesFilter(user, filter)) .count(); } private boolean matchesFilter(ScimUser user, ScimFilter filter) { if (filter == null) { return true; } // Implement filter matching logic // This is a simplified version - real implementation would parse filter expressions return true; } private void applyPatchOperation(ScimUser user, ScimPatchOperation operation) { switch (operation.getOp()) { case "add": applyAddOperation(user, operation); break; case "remove": applyRemoveOperation(user, operation); break; case "replace": applyReplaceOperation(user, operation); break; default: throw new UnsupportedOperationException("Unsupported patch operation: " + operation.getOp()); } } private void applyAddOperation(ScimUser user, ScimPatchOperation operation) { // Implementation for add operation } private void applyRemoveOperation(ScimUser user, ScimPatchOperation operation) { // Implementation for remove operation } private void applyReplaceOperation(ScimUser user, ScimPatchOperation operation) { // Implementation for replace operation } } @Repository public interface ScimUserRepository extends JpaRepository<ScimUser, String> { Optional<ScimUser> findByUserName(String userName); Optional<ScimUser> findByExternalId(String externalId); boolean existsByUserName(String userName); }
2. Group Service
@Service @Transactional @Slf4j public class ScimGroupService { private final ScimGroupRepository groupRepository; private final ScimUserRepository userRepository; private final ScimMapper scimMapper; public ScimGroupService(ScimGroupRepository groupRepository, ScimUserRepository userRepository, ScimMapper scimMapper) { this.groupRepository = groupRepository; this.userRepository = userRepository; this.scimMapper = scimMapper; } public ScimGroup createGroup(ScimGroup group) { log.info("Creating SCIM group: {}", group.getDisplayName()); group.setId(UUID.randomUUID().toString()); group.setMetaCreated(Instant.now()); group.setMetaLastModified(Instant.now()); ScimGroup savedGroup = groupRepository.save(group); log.info("Created SCIM group with ID: {}", savedGroup.getId()); return savedGroup; } public Optional<ScimGroup> getGroupById(String id) { return groupRepository.findById(id); } public List<ScimGroup> getGroups(ScimFilter filter, int startIndex, int count) { log.debug("Fetching groups with filter: {}, startIndex: {}, count: {}", filter, startIndex, count); List<ScimGroup> allGroups = groupRepository.findAll(); return allGroups.stream() .filter(group -> matchesFilter(group, filter)) .skip(startIndex - 1) .limit(count) .collect(Collectors.toList()); } public ScimGroup updateGroup(String id, ScimGroup updatedGroup) { log.info("Updating SCIM group: {}", id); ScimGroup existingGroup = groupRepository.findById(id) .orElseThrow(() -> new GroupNotFoundException("Group not found: " + id)); scimMapper.updateGroupFromGroup(updatedGroup, existingGroup); existingGroup.setMetaLastModified(Instant.now()); ScimGroup savedGroup = groupRepository.save(existingGroup); log.info("Updated SCIM group: {}", id); return savedGroup; } public void deleteGroup(String id) { log.info("Deleting SCIM group: {}", id); if (!groupRepository.existsById(id)) { throw new GroupNotFoundException("Group not found: " + id); } groupRepository.deleteById(id); log.info("Deleted SCIM group: {}", id); } public ScimGroup addMemberToGroup(String groupId, String userId) { log.info("Adding user {} to group {}", userId, groupId); ScimGroup group = groupRepository.findById(groupId) .orElseThrow(() -> new GroupNotFoundException("Group not found: " + groupId)); ScimUser user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException("User not found: " + userId)); if (group.getMembers() == null) { group.setMembers(new ArrayList<>()); } // Check if user is already a member boolean alreadyMember = group.getMembers().stream() .anyMatch(member -> member.getId().equals(userId)); if (!alreadyMember) { group.getMembers().add(user); group.setMetaLastModified(Instant.now()); group = groupRepository.save(group); log.info("User {} added to group {}", userId, groupId); } return group; } public ScimGroup removeMemberFromGroup(String groupId, String userId) { log.info("Removing user {} from group {}", userId, groupId); ScimGroup group = groupRepository.findById(groupId) .orElseThrow(() -> new GroupNotFoundException("Group not found: " + groupId)); if (group.getMembers() != null) { boolean removed = group.getMembers().removeIf(member -> member.getId().equals(userId)); if (removed) { group.setMetaLastModified(Instant.now()); group = groupRepository.save(group); log.info("User {} removed from group {}", userId, groupId); } } return group; } public long getGroupCount(ScimFilter filter) { List<ScimGroup> allGroups = groupRepository.findAll(); return allGroups.stream() .filter(group -> matchesFilter(group, filter)) .count(); } private boolean matchesFilter(ScimGroup group, ScimFilter filter) { if (filter == null) { return true; } // Implement filter matching logic return true; } } @Repository public interface ScimGroupRepository extends JpaRepository<ScimGroup, String> { Optional<ScimGroup> findByExternalId(String externalId); List<ScimGroup> findByDisplayNameContainingIgnoreCase(String displayName); List<ScimGroup> findByMembersId(String memberId); }
3. SCIM Mapper
@Component public class ScimMapper { public void updateUserFromUser(ScimUser source, ScimUser target) { if (source.getUserName() != null) { target.setUserName(source.getUserName()); } if (source.getDisplayName() != null) { target.setDisplayName(source.getDisplayName()); } if (source.getExternalId() != null) { target.setExternalId(source.getExternalId()); } if (source.getActive() != null) { target.setActive(source.getActive()); } if (source.getLocked() != null) { target.setLocked(source.getLocked()); } if (source.getName() != null) { target.setName(source.getName()); } if (source.getEmails() != null) { target.setEmails(source.getEmails()); } if (source.getPhoneNumbers() != null) { target.setPhoneNumbers(source.getPhoneNumbers()); } // Update other fields as needed } public void updateGroupFromGroup(ScimGroup source, ScimGroup target) { if (source.getDisplayName() != null) { target.setDisplayName(source.getDisplayName()); } if (source.getDescription() != null) { target.setDescription(source.getDescription()); } if (source.getExternalId() != null) { target.setExternalId(source.getExternalId()); } if (source.getMembers() != null) { target.setMembers(source.getMembers()); } } public ScimUser toScimUser(UserJson userJson) { // Convert from JSON representation to entity return ScimUser.builder() .id(userJson.getId()) .externalId(userJson.getExternalId()) .userName(userJson.getUserName()) .displayName(userJson.getDisplayName()) .active(userJson.getActive()) .build(); // Map other fields } public UserJson toUserJson(ScimUser user) { // Convert from entity to JSON representation UserJson userJson = new UserJson(); userJson.setId(user.getId()); userJson.setExternalId(user.getExternalId()); userJson.setUserName(user.getUserName()); userJson.setDisplayName(user.getDisplayName()); userJson.setActive(user.getActive()); // Map other fields return userJson; } }

SCIM Controllers

1. User Controller
@RestController @RequestMapping("/Users") @Validated @Slf4j public class ScimUserController { private final ScimUserService userService; private final ScimMapper scimMapper; private final ScimResponseBuilder responseBuilder; public ScimUserController(ScimUserService userService, ScimMapper scimMapper, ScimResponseBuilder responseBuilder) { this.userService = userService; this.scimMapper = scimMapper; this.responseBuilder = responseBuilder; } @PostMapping public ResponseEntity<UserResponse> createUser(@Valid @RequestBody UserRequest request) { log.info("Creating user: {}", request.getUserName()); try { ScimUser user = scimMapper.toScimUser(request); ScimUser createdUser = userService.createUser(user); UserResponse response = scimMapper.toUserResponse(createdUser); return ResponseEntity.status(HttpStatus.CREATED) .header("Location", response.getMeta().getLocation()) .body(response); } catch (DuplicateUserException e) { log.warn("Duplicate user creation attempt: {}", request.getUserName()); return ResponseEntity.status(HttpStatus.CONFLICT).build(); } } @GetMapping("/{id}") public ResponseEntity<UserResponse> getUser(@PathVariable String id) { log.debug("Fetching user: {}", id); return userService.getUserById(id) .map(user -> { UserResponse response = scimMapper.toUserResponse(user); return ResponseEntity.ok(response); }) .orElse(ResponseEntity.notFound().build()); } @GetMapping public ResponseEntity<ListResponse<UserResponse>> getUsers( @RequestParam(required = false) String filter, @RequestParam(defaultValue = "1") int startIndex, @RequestParam(defaultValue = "100") int count, @RequestParam(required = false) String sortBy, @RequestParam(required = false) String sortOrder) { log.debug("Searching users - filter: {}, startIndex: {}, count: {}", filter, startIndex, count); ScimFilter scimFilter = parseFilter(filter); List<ScimUser> users = userService.getUsers(scimFilter, startIndex, count); long totalResults = userService.getUserCount(scimFilter); List<UserResponse> userResponses = users.stream() .map(scimMapper::toUserResponse) .collect(Collectors.toList()); ListResponse<UserResponse> response = responseBuilder.buildListResponse( userResponses, startIndex, count, totalResults); return ResponseEntity.ok(response); } @PutMapping("/{id}") public ResponseEntity<UserResponse> updateUser(@PathVariable String id, @Valid @RequestBody UserRequest request) { log.info("Updating user: {}", id); try { ScimUser user = scimMapper.toScimUser(request); ScimUser updatedUser = userService.updateUser(id, user); UserResponse response = scimMapper.toUserResponse(updatedUser); return ResponseEntity.ok(response); } catch (UserNotFoundException e) { log.warn("User not found for update: {}", id); return ResponseEntity.notFound().build(); } } @PatchMapping("/{id}") public ResponseEntity<UserResponse> patchUser(@PathVariable String id, @RequestBody PatchRequest request) { log.info("Patching user: {}", id); try { ScimPatchOperation patchOperation = toScimPatchOperation(request); ScimUser updatedUser = userService.patchUser(id, patchOperation); UserResponse response = scimMapper.toUserResponse(updatedUser); return ResponseEntity.ok(response); } catch (UserNotFoundException e) { log.warn("User not found for patch: {}", id); return ResponseEntity.notFound().build(); } catch (UnsupportedOperationException e) { log.warn("Unsupported patch operation: {}", e.getMessage()); return ResponseEntity.badRequest().build(); } } @DeleteMapping("/{id}") public ResponseEntity<Void> deleteUser(@PathVariable String id) { log.info("Deleting user: {}", id); try { userService.deleteUser(id); return ResponseEntity.noContent().build(); } catch (UserNotFoundException e) { log.warn("User not found for deletion: {}", id); return ResponseEntity.notFound().build(); } } private ScimFilter parseFilter(String filter) { if (filter == null || filter.trim().isEmpty()) { return null; } // Implement filter parsing logic // This would parse SCIM filter expressions like "userName eq \"[email protected]\"" return new ScimFilter(filter); } private ScimPatchOperation toScimPatchOperation(PatchRequest request) { // Convert PatchRequest to ScimPatchOperation return new ScimPatchOperation( request.getOp(), request.getPath(), request.getValue() ); } }
2. Group Controller
@RestController @RequestMapping("/Groups") @Validated @Slf4j public class ScimGroupController { private final ScimGroupService groupService; private final ScimMapper scimMapper; private final ScimResponseBuilder responseBuilder; public ScimGroupController(ScimGroupService groupService, ScimMapper scimMapper, ScimResponseBuilder responseBuilder) { this.groupService = groupService; this.scimMapper = scimMapper; this.responseBuilder = responseBuilder; } @PostMapping public ResponseEntity<GroupResponse> createGroup(@Valid @RequestBody GroupRequest request) { log.info("Creating group: {}", request.getDisplayName()); ScimGroup group = scimMapper.toScimGroup(request); ScimGroup createdGroup = groupService.createGroup(group); GroupResponse response = scimMapper.toGroupResponse(createdGroup); return ResponseEntity.status(HttpStatus.CREATED) .header("Location", response.getMeta().getLocation()) .body(response); } @GetMapping("/{id}") public ResponseEntity<GroupResponse> getGroup(@PathVariable String id) { log.debug("Fetching group: {}", id); return groupService.getGroupById(id) .map(group -> { GroupResponse response = scimMapper.toGroupResponse(group); return ResponseEntity.ok(response); }) .orElse(ResponseEntity.notFound().build()); } @GetMapping public ResponseEntity<ListResponse<GroupResponse>> getGroups( @RequestParam(required = false) String filter, @RequestParam(defaultValue = "1") int startIndex, @RequestParam(defaultValue = "100") int count) { log.debug("Searching groups - filter: {}, startIndex: {}, count: {}", filter, startIndex, count); ScimFilter scimFilter = parseFilter(filter); List<ScimGroup> groups = groupService.getGroups(scimFilter, startIndex, count); long totalResults = groupService.getGroupCount(scimFilter); List<GroupResponse> groupResponses = groups.stream() .map(scimMapper::toGroupResponse) .collect(Collectors.toList()); ListResponse<GroupResponse> response = responseBuilder.buildListResponse( groupResponses, startIndex, count, totalResults); return ResponseEntity.ok(response); } @PutMapping("/{id}") public ResponseEntity<GroupResponse> updateGroup(@PathVariable String id, @Valid @RequestBody GroupRequest request) { log.info("Updating group: {}", id); try { ScimGroup group = scimMapper.toScimGroup(request); ScimGroup updatedGroup = groupService.updateGroup(id, group); GroupResponse response = scimMapper.toGroupResponse(updatedGroup); return ResponseEntity.ok(response); } catch (GroupNotFoundException e) { log.warn("Group not found for update: {}", id); return ResponseEntity.notFound().build(); } } @DeleteMapping("/{id}") public ResponseEntity<Void> deleteGroup(@PathVariable String id) { log.info("Deleting group: {}", id); try { groupService.deleteGroup(id); return ResponseEntity.noContent().build(); } catch (GroupNotFoundException e) { log.warn("Group not found for deletion: {}", id); return ResponseEntity.notFound().build(); } } @PostMapping("/{groupId}/members/{userId}") public ResponseEntity<GroupResponse> addMember(@PathVariable String groupId, @PathVariable String userId) { log.info("Adding member {} to group {}", userId, groupId); try { ScimGroup group = groupService.addMemberToGroup(groupId, userId); GroupResponse response = scimMapper.toGroupResponse(group); return ResponseEntity.ok(response); } catch (GroupNotFoundException | UserNotFoundException e) { log.warn("Failed to add member: {}", e.getMessage()); return ResponseEntity.notFound().build(); } } @DeleteMapping("/{groupId}/members/{userId}") public ResponseEntity<GroupResponse> removeMember(@PathVariable String groupId, @PathVariable String userId) { log.info("Removing member {} from group {}", userId, groupId); try { ScimGroup group = groupService.removeMemberFromGroup(groupId, userId); GroupResponse response = scimMapper.toGroupResponse(group); return ResponseEntity.ok(response); } catch (GroupNotFoundException e) { log.warn("Group not found: {}", groupId); return ResponseEntity.notFound().build(); } } private ScimFilter parseFilter(String filter) { // Implement filter parsing return new ScimFilter(filter); } }
3. Service Provider Config Controller
@RestController @RequestMapping("/ServiceProviderConfig") @Slf4j public class ScimServiceProviderConfigController { private final ScimConfigurationService configService; public ScimServiceProviderConfigController(ScimConfigurationService configService) { this.configService = configService; } @GetMapping public ResponseEntity<ServiceProviderConfigResponse> getServiceProviderConfig() { log.debug("Fetching service provider configuration"); ScimConfiguration config = configService.getDefaultConfiguration(); ServiceProviderConfigResponse response = buildConfigResponse(config); return ResponseEntity.ok(response); } private ServiceProviderConfigResponse buildConfigResponse(ScimConfiguration config) { ServiceProviderConfigResponse response = new ServiceProviderConfigResponse(); // Set supported features response.setPatchSupported(config.getPatchSupported()); response.setBulkSupported(config.getBulkSupported()); response.setFilterSupported(config.getFilterSupported()); response.setEtagSupported(config.getEtagSupported()); response.setSortSupported(config.getSortSupported()); response.setChangePasswordSupported(config.getChangePasswordSupported()); // Set bulk configuration BulkConfig bulkConfig = new BulkConfig(); bulkConfig.setSupported(config.getBulkSupported()); bulkConfig.setMaxOperations(config.getMaxResults()); bulkConfig.setMaxPayloadSize(config.getMaxPayloadSize()); response.setBulk(bulkConfig); // Set filter configuration FilterConfig filterConfig = new FilterConfig(); filterConfig.setSupported(config.getFilterSupported()); filterConfig.setMaxResults(config.getMaxResults()); response.setFilter(filterConfig); // Set authentication schemes List<AuthenticationScheme> authSchemes = config.getAuthenticationSchemes().stream() .map(this::toAuthenticationScheme) .collect(Collectors.toList()); response.setAuthenticationSchemes(authSchemes); return response; } private AuthenticationScheme toAuthenticationScheme( ScimConfiguration.AuthenticationScheme scheme) { AuthenticationScheme authScheme = new AuthenticationScheme(); authScheme.setType(scheme.getType()); authScheme.setName(scheme.getName()); authScheme.setDescription(scheme.getDescription()); authScheme.setSpecUri(scheme.getSpecUri()); authScheme.setDocumentationUri(scheme.getDocumentationUri()); return authScheme; } }

SCIM Request/Response Models

1. Request Models
@Data @JsonIgnoreProperties(ignoreUnknown = true) public class UserRequest { @NotEmpty private String userName; private String externalId; @NotEmpty private String displayName; private Boolean active = true; private Name name; private List<Email> emails; private List<PhoneNumber> phoneNumbers; @Data public static class Name { private String formatted; private String familyName; private String givenName; private String middleName; private String honorificPrefix; private String honorificSuffix; } @Data public static class Email { @NotEmpty private String value; private String type; private Boolean primary = false; } @Data public static class PhoneNumber { @NotEmpty private String value; private String type; } } @Data @JsonIgnoreProperties(ignoreUnknown = true) public class GroupRequest { @NotEmpty private String displayName; private String externalId; private String description; private List<Member> members; @Data public static class Member { @NotEmpty private String value; private String display; private String type = "User"; } } @Data @JsonIgnoreProperties(ignoreUnknown = true) public class PatchRequest { @NotEmpty private List<String> schemas = Arrays.asList("urn:ietf:params:scim:api:messages:2.0:PatchOp"); @NotEmpty private List<Operation> Operations; @Data public static class Operation { @NotEmpty private String op; // add, remove, replace private String path; private Object value; } } @Data public class BulkRequest { @NotEmpty private List<String> schemas = Arrays.asList("urn:ietf:params:scim:api:messages:2.0:BulkRequest"); private List<BulkOperation> Operations; private Integer failOnErrors; @Data public static class BulkOperation { private String method; // POST, PUT, PATCH, DELETE private String bulkId; private String path; private Object data; } }
2. Response Models
@Data @JsonInclude(JsonInclude.Include.NON_NULL) public class UserResponse { @NotEmpty private List<String> schemas = Arrays.asList("urn:ietf:params:scim:schemas:core:2.0:User"); @NotEmpty private String id; private String externalId; @NotEmpty private String userName; @NotEmpty private String displayName; private Boolean active = true; private UserRequest.Name name; private List<UserRequest.Email> emails; private List<UserRequest.PhoneNumber> phoneNumbers; private Meta meta; @Data public static class Meta { private String resourceType = "User"; private Instant created; private Instant lastModified; private String location; private String version; } } @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class GroupResponse { @NotEmpty private List<String> schemas = Arrays.asList("urn:ietf:params:scim:schemas:core:2.0:Group"); @NotEmpty private String id; private String externalId; @NotEmpty private String displayName; private String description; private List<GroupRequest.Member> members; private Meta meta; } @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class ListResponse<T> { @NotEmpty private List<String> schemas = Arrays.asList("urn:ietf:params:scim:api:messages:2.0:ListResponse"); @NotEmpty private List<T> Resources; private int totalResults; private int startIndex; private int itemsPerPage; } @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class ServiceProviderConfigResponse { @NotEmpty private List<String> schemas = Arrays.asList("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"); private BulkConfig bulk; private FilterConfig filter; private Boolean patchSupported; private Boolean etagSupported; private Boolean sortSupported; private Boolean changePasswordSupported; private List<AuthenticationScheme> authenticationSchemes; @Data public static class BulkConfig { private Boolean supported; private Integer maxOperations; private Integer maxPayloadSize; } @Data public static class FilterConfig { private Boolean supported; private Integer maxResults; } @Data public static class AuthenticationScheme { private String type; private String name; private String description; private String specUri; private String documentationUri; } } @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class BulkResponse { @NotEmpty private List<String> schemas = Arrays.asList("urn:ietf:params:scim:api:messages:2.0:BulkResponse"); private List<BulkOperationResponse> Operations; @Data public static class BulkOperationResponse { private String method; private String bulkId; private String location; private Integer status; private Object response; private ErrorResponse error; } }
3. Error Responses
@Data @JsonInclude(JsonInclude.Include.NON_NULL) public class ErrorResponse { @NotEmpty private List<String> schemas = Arrays.asList("urn:ietf:params:scim:api:messages:2.0:Error"); @NotEmpty private String scimType; @NotEmpty private String detail; private Integer status; public static ErrorResponse invalidFilter(String detail) { ErrorResponse error = new ErrorResponse(); error.setScimType("invalidFilter"); error.setDetail(detail); error.setStatus(400); return error; } public static ErrorResponse tooMany(String detail) { ErrorResponse error = new ErrorResponse(); error.setScimType("tooMany"); error.setDetail(detail); error.setStatus(400); return error; } public static ErrorResponse uniqueness(String detail) { ErrorResponse error = new ErrorResponse(); error.setScimType("uniqueness"); error.setDetail(detail); error.setStatus(409); return error; } public static ErrorResponse mutability(String detail) { ErrorResponse error = new ErrorResponse(); error.setScimType("mutability"); error.setDetail(detail); error.setStatus(400); return error; } public static ErrorResponse invalidSyntax(String detail) { ErrorResponse error = new ErrorResponse(); error.setScimType("invalidSyntax"); error.setDetail(detail); error.setStatus(400); return error; } public static ErrorResponse invalidPath(String detail) { ErrorResponse error = new ErrorResponse(); error.setScimType("invalidPath"); error.setDetail(detail); error.setStatus(400); return error; } public static ErrorResponse noTarget(String detail) { ErrorResponse error = new ErrorResponse(); error.setScimType("noTarget"); error.setDetail(detail); error.setStatus(400); return error; } public static ErrorResponse invalidValue(String detail) { ErrorResponse error = new ErrorResponse(); error.setScimType("invalidValue"); error.setDetail(detail); error.setStatus(400); return error; } public static ErrorResponse invalidVers(String detail) { ErrorResponse error = new ErrorResponse(); error.setScimType("invalidVers"); error.setDetail(detail); error.setStatus(400); return error; } public static ErrorResponse sensitive(String detail) { ErrorResponse error = new ErrorResponse(); error.setScimType("sensitive"); error.setDetail(detail); error.setStatus(400); return error; } }

Security Configuration

@Configuration @EnableWebSecurity @EnableMethodSecurity public class ScimSecurityConfig { @Value("${scim.auth.bearer-token:default-token}") private String bearerToken; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .securityMatcher("/scim/v2/**") .authorizeHttpRequests(authz -> authz .anyRequest().authenticated() ) .httpBasic(Customizer.withDefaults()) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .csrf(csrf -> csrf.disable()); return http.build(); } @Bean public UserDetailsService userDetailsService() { UserDetails user = User.builder() .username("scim-client") .password("{noop}scim-password") // Use proper password encoding in production .roles("SCIM_CLIENT") .build(); return new InMemoryUserDetailsManager(user); } @Bean public FilterRegistrationBean<ScimBearerAuthFilter> scimBearerAuthFilter() { FilterRegistrationBean<ScimBearerAuthFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new ScimBearerAuthFilter(bearerToken)); registrationBean.addUrlPatterns("/scim/v2/*"); return registrationBean; } } @Component public class ScimBearerAuthFilter extends OncePerRequestFilter { private final String expectedToken; public ScimBearerAuthFilter(String expectedToken) { this.expectedToken = expectedToken; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); if (expectedToken.equals(token)) { filterChain.doFilter(request, response); return; } } response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("{\"error\": \"Invalid or missing bearer token\"}"); } }

Exception Handling

@ControllerAdvice @Slf4j public class ScimExceptionHandler { @ExceptionHandler(UserNotFoundException.class) public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) { log.warn("User not found: {}", e.getMessage()); ErrorResponse error = ErrorResponse.invalidValue("User not found: " + e.getMessage()); error.setStatus(404); return ResponseEntity.status(404).body(error); } @ExceptionHandler(GroupNotFoundException.class) public ResponseEntity<ErrorResponse> handleGroupNotFound(GroupNotFoundException e) { log.warn("Group not found: {}", e.getMessage()); ErrorResponse error = ErrorResponse.invalidValue("Group not found: " + e.getMessage()); error.setStatus(404); return ResponseEntity.status(404).body(error); } @ExceptionHandler(DuplicateUserException.class) public ResponseEntity<ErrorResponse> handleDuplicateUser(DuplicateUserException e) { log.warn("Duplicate user: {}", e.getMessage()); ErrorResponse error = ErrorResponse.uniqueness("User already exists: " + e.getMessage()); return ResponseEntity.status(409).body(error); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException e) { log.warn("Validation error: {}", e.getMessage()); String errorMessage = e.getBindingResult().getFieldErrors().stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.joining(", ")); ErrorResponse error = ErrorResponse.invalidSyntax(errorMessage); return ResponseEntity.status(400).body(error); } @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleGenericException(Exception e) { log.error("Unexpected error in SCIM endpoint", e); ErrorResponse error = ErrorResponse.invalidSyntax("Internal server error"); error.setStatus(500); return ResponseEntity.status(500).body(error); } } public class UserNotFoundException extends RuntimeException { public UserNotFoundException(String message) { super(message); } } public class GroupNotFoundException extends RuntimeException { public GroupNotFoundException(String message) { super(message); } } public class DuplicateUserException extends RuntimeException { public DuplicateUserException(String message) { super(message); } }

Testing

1. Integration Tests
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @TestInstance(TestInstance.Lifecycle.PER_CLASS) class ScimUserControllerIntegrationTest { @Autowired private TestRestTemplate restTemplate; @Autowired private ScimUserRepository userRepository; @BeforeEach void cleanup() { userRepository.deleteAll(); } @Test void testCreateUser() { UserRequest request = new UserRequest(); request.setUserName("[email protected]"); request.setDisplayName("John Doe"); HttpEntity<UserRequest> entity = new HttpEntity<>(request, createAuthHeaders()); ResponseEntity<UserResponse> response = restTemplate.exchange( "/Users", HttpMethod.POST, entity, UserResponse.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(response.getBody().getUserName()).isEqualTo("[email protected]"); assertThat(response.getBody().getId()).isNotNull(); assertThat(response.getHeaders().getLocation()).isNotNull(); } @Test void testGetUser() { // First create a user ScimUser user = createTestUser(); userRepository.save(user); HttpEntity<Void> entity = new HttpEntity<>(createAuthHeaders()); ResponseEntity<UserResponse> response = restTemplate.exchange( "/Users/" + user.getId(), HttpMethod.GET, entity, UserResponse.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody().getUserName()).isEqualTo("[email protected]"); } @Test void testUpdateUser() { ScimUser user = createTestUser(); userRepository.save(user); UserRequest request = new UserRequest(); request.setUserName("[email protected]"); request.setDisplayName("Updated User"); HttpEntity<UserRequest> entity = new HttpEntity<>(request, createAuthHeaders()); ResponseEntity<UserResponse> response = restTemplate.exchange( "/Users/" + user.getId(), HttpMethod.PUT, entity, UserResponse.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody().getUserName()).isEqualTo("[email protected]"); } private ScimUser createTestUser() { return ScimUser.builder() .id(UUID.randomUUID().toString()) .userName("[email protected]") .displayName("Test User") .active(true) .metaCreated(Instant.now()) .metaLastModified(Instant.now()) .build(); } private HttpHeaders createAuthHeaders() { HttpHeaders headers = new HttpHeaders(); headers.setBasicAuth("scim-client", "scim-password"); return headers; } }
2. SCIM Client Test
@Component @Slf4j public class ScimClientTest { private final ScimUserService userService; private final ScimGroupService groupService; public void testScimOperations() { // Test user creation ScimUser user = ScimUser.builder() .userName("[email protected]") .displayName("Alice Smith") .active(true) .name(ScimUser.Name.builder() .givenName("Alice") .familyName("Smith") .build()) .emails(List.of( ScimUser.Email.builder() .value("[email protected]") .type("work") .primary(true) .build() )) .build(); ScimUser createdUser = userService.createUser(user); log.info("Created user: {}", createdUser.getId()); // Test group creation ScimGroup group = ScimGroup.builder() .displayName("Developers") .description("Software development team") .members(List.of(createdUser)) .build(); ScimGroup createdGroup = groupService.createGroup(group); log.info("Created group: {}", createdGroup.getId()); // Test user search List<ScimUser> users = userService.getUsers(null, 1, 10); log.info("Found {} users", users.size()); // Test group membership ScimGroup updatedGroup = groupService.addMemberToGroup( createdGroup.getId(), createdUser.getId()); log.info("Group now has {} members", updatedGroup.getMembers().size()); } }

Best Practices

  1. Idempotency: Ensure PUT operations are idempotent
  2. Filter Support: Implement comprehensive filter support
  3. Bulk Operations: Handle bulk operations efficiently
  4. Error Handling: Provide detailed SCIM-compliant error responses
  5. Security: Implement proper authentication and authorization
  6. Validation: Validate all incoming SCIM requests
  7. Logging: Log all provisioning activities for audit purposes
  8. Performance: Implement pagination for large datasets
// Example of idempotent user creation @Service public class IdempotentScimService { public ScimUser createUserIfNotExists(ScimUser user) { return userService.getUserByUserName(user.getUserName()) .orElseGet(() -> userService.createUser(user)); } }

Conclusion

SCIM provisioning in Java provides:

  • Standardized user management across different systems
  • Automated provisioning and deprovisioning
  • Consistent API for identity management
  • Enterprise-ready with proper security and error handling

This implementation supports the full SCIM 2.0 specification including users, groups, filtering, pagination, bulk operations, and proper error handling. It can be integrated with any SCIM-compliant identity provider or service provider.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper