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