Introduction
SCIM (System for Cross-domain Identity Management) is a standardized protocol for automating user identity provisioning between identity providers and service providers. It enables seamless user lifecycle management across cloud applications and enterprise systems. This guide explores how to implement SCIM 2.0 provisioning in Java applications.
Article: Implementing SCIM 2.0 Provisioning in Java Applications
SCIM provides a RESTful API for creating, reading, updating, and deleting user identities and groups. Implementing SCIM in Java enables interoperability with identity providers like Okta, Azure AD, OneLogin, and other SCIM-compliant systems.
1. SCIM 2.0 Architecture Overview
Key Components:
- SCIM Client - Identity provider (IdP) that provisions users
- SCIM Server - Service provider (SP) that consumes provisioning requests
- SCIM Schema - Standardized user and group representations
- REST Endpoints - /Users, /Groups, /ServiceProviderConfig, /ResourceTypes
- Authentication - Bearer tokens, OAuth2, or other authentication methods
Core SCIM Operations:
- User Provisioning - Create, read, update, delete users
- Group Management - Create, read, update, delete groups
- Bulk Operations - Process multiple operations in one request
- Filtering & Pagination - Search and retrieve specific resources
- Schema Discovery - Service provider capabilities
2. Maven Dependencies
pom.xml:
<properties>
<spring-boot.version>3.1.0</spring-boot.version>
<scim-sdk.version>2.2.8</scim-sdk.version>
<jackson.version>2.15.2</jackson.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-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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</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>
<version>42.6.0</version>
</dependency>
<!-- Utilities -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.13.0</version>
</dependency>
</dependencies>
3. Application Configuration
application.yml:
server:
port: 8080
servlet:
context-path: /scim/v2
spring:
datasource:
url: jdbc:postgresql://localhost:5432/scim_provisioning
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:password}
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
scim:
# SCIM Server Configuration
base-url: http://localhost:8080/scim/v2
bulk:
max-operations: 1000
max-payload-size: 1048576
filter:
max-results: 1000
patch:
supported: true
sorting:
supported: true
etag:
supported: true
password:
supported: false # Set to true if you support password writes
# Authentication
auth:
type: BEARER # BEARER, BASIC, OAUTH2
bearer-token: ${SCIM_BEARER_TOKEN:scim-token-123}
# Schema Configuration
schema:
core-user: urn:ietf:params:scim:schemas:core:2.0:User
core-group: urn:ietf:params:scim:schemas:core:2.0:Group
enterprise-user: urn:ietf:params:scim:schemas:extension:enterprise:2.0:User
custom-user: urn:your-company:params:scim:schemas:extension:custom:2.0:User
logging:
level:
com.yourcompany.scim: DEBUG
org.springframework.security: INFO
4. SCIM Domain Models
SCIM User Entity:
package com.myapp.scim.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.*;
@Entity
@Table(name = "scim_users", uniqueConstraints = {
@UniqueConstraint(columnNames = "userName"),
@UniqueConstraint(columnNames = "externalId")
})
public class ScimUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
private String scimId; // SCIM resource identifier
@NotNull
private String externalId; // External system identifier
@NotNull
private String userName;
@NotNull
private Name name;
@Email
@NotNull
private String email;
private Boolean active = true;
@ElementCollection
@CollectionTable(name = "scim_user_emails", joinColumns = @JoinColumn(name = "user_id"))
private List<Email> emails = new ArrayList<>();
@ElementCollection
@CollectionTable(name = "scim_user_phone_numbers", joinColumns = @JoinColumn(name = "user_id"))
private List<PhoneNumber> phoneNumbers = new ArrayList<>();
@ElementCollection
@CollectionTable(name = "scim_user_addresses", joinColumns = @JoinColumn(name = "user_id"))
private List<Address> addresses = new ArrayList<>();
@ElementCollection
@CollectionTable(name = "scim_user_roles", joinColumns = @JoinColumn(name = "user_id"))
private Set<String> roles = new HashSet<>();
private String timezone;
private String locale;
private String profileUrl;
private String title;
private String preferredLanguage;
// Enterprise User Schema Extension
private String employeeNumber;
private String costCenter;
private String organization;
private String division;
private String department;
private Manager manager;
// Metadata
private String resourceType = "User";
private LocalDateTime created;
private LocalDateTime lastModified;
private String location; // Resource URL
private String version; // ETag version
@Embedded
private Meta meta;
// Constructors
public ScimUser() {
this.scimId = UUID.randomUUID().toString();
this.created = LocalDateTime.now();
this.lastModified = LocalDateTime.now();
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getScimId() { return scimId; }
public void setScimId(String scimId) { this.scimId = scimId; }
public String getExternalId() { return externalId; }
public void setExternalId(String externalId) { this.externalId = externalId; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public Name getName() { return name; }
public void setName(Name name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public Boolean getActive() { return active; }
public void setActive(Boolean active) { this.active = active; }
public List<Email> getEmails() { return emails; }
public void setEmails(List<Email> emails) { this.emails = emails; }
public List<PhoneNumber> getPhoneNumbers() { return phoneNumbers; }
public void setPhoneNumbers(List<PhoneNumber> phoneNumbers) { this.phoneNumbers = phoneNumbers; }
public List<Address> getAddresses() { return addresses; }
public void setAddresses(List<Address> addresses) { this.addresses = addresses; }
public Set<String> getRoles() { return roles; }
public void setRoles(Set<String> roles) { this.roles = roles; }
public String getTimezone() { return timezone; }
public void setTimezone(String timezone) { this.timezone = timezone; }
public String getLocale() { return locale; }
public void setLocale(String locale) { this.locale = locale; }
public String getProfileUrl() { return profileUrl; }
public void setProfileUrl(String profileUrl) { this.profileUrl = profileUrl; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getPreferredLanguage() { return preferredLanguage; }
public void setPreferredLanguage(String preferredLanguage) { this.preferredLanguage = preferredLanguage; }
public String getEmployeeNumber() { return employeeNumber; }
public void setEmployeeNumber(String employeeNumber) { this.employeeNumber = employeeNumber; }
public String getCostCenter() { return costCenter; }
public void setCostCenter(String costCenter) { this.costCenter = costCenter; }
public String getOrganization() { return organization; }
public void setOrganization(String organization) { this.organization = organization; }
public String getDivision() { return division; }
public void setDivision(String division) { this.division = division; }
public String getDepartment() { return department; }
public void setDepartment(String department) { this.department = department; }
public Manager getManager() { return manager; }
public void setManager(Manager manager) { this.manager = manager; }
public String getResourceType() { return resourceType; }
public void setResourceType(String resourceType) { this.resourceType = resourceType; }
public LocalDateTime getCreated() { return created; }
public void setCreated(LocalDateTime created) { this.created = created; }
public LocalDateTime getLastModified() { return lastModified; }
public void setLastModified(LocalDateTime lastModified) { this.lastModified = lastModified; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
public Meta getMeta() { return meta; }
public void setMeta(Meta meta) { this.meta = meta; }
@PreUpdate
public void preUpdate() {
this.lastModified = LocalDateTime.now();
this.version = UUID.randomUUID().toString();
}
// Embedded classes for complex types
@Embeddable
public static class Name {
private String formatted;
private String familyName;
private String givenName;
private String middleName;
private String honorificPrefix;
private String honorificSuffix;
// Constructors, getters, and setters
public Name() {}
public Name(String givenName, String familyName) {
this.givenName = givenName;
this.familyName = familyName;
this.formatted = givenName + " " + familyName;
}
public String getFormatted() { return formatted; }
public void setFormatted(String formatted) { this.formatted = formatted; }
public String getFamilyName() { return familyName; }
public void setFamilyName(String familyName) { this.familyName = familyName; }
public String getGivenName() { return givenName; }
public void setGivenName(String givenName) { this.givenName = givenName; }
public String getMiddleName() { return middleName; }
public void setMiddleName(String middleName) { this.middleName = middleName; }
public String getHonorificPrefix() { return honorificPrefix; }
public void setHonorificPrefix(String honorificPrefix) { this.honorificPrefix = honorificPrefix; }
public String getHonorificSuffix() { return honorificSuffix; }
public void setHonorificSuffix(String honorificSuffix) { this.honorificSuffix = honorificSuffix; }
}
@Embeddable
public static class Email {
private String value;
private String type; // work, home, other
private Boolean primary = false;
// Getters and setters
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public Boolean getPrimary() { return primary; }
public void setPrimary(Boolean primary) { this.primary = primary; }
}
@Embeddable
public static class PhoneNumber {
private String value;
private String type; // work, home, mobile, fax, pager, other
// Getters and setters
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
}
@Embeddable
public static class Address {
private String formatted;
private String streetAddress;
private String locality;
private String region;
private String postalCode;
private String country;
private String type; // work, home, other
// Getters and setters
public String getFormatted() { return formatted; }
public void setFormatted(String formatted) { this.formatted = formatted; }
public String getStreetAddress() { return streetAddress; }
public void setStreetAddress(String streetAddress) { this.streetAddress = streetAddress; }
public String getLocality() { return locality; }
public void setLocality(String locality) { this.locality = locality; }
public String getRegion() { return region; }
public void setRegion(String region) { this.region = region; }
public String getPostalCode() { return postalCode; }
public void setPostalCode(String postalCode) { this.postalCode = postalCode; }
public String getCountry() { return country; }
public void setCountry(String country) { this.country = country; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
}
@Embeddable
public static class Manager {
private String value; // Manager's SCIM id
private String ref; // Manager's resource URL
private String displayName;
// Getters and setters
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getRef() { return ref; }
public void setRef(String ref) { this.ref = ref; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
}
@Embeddable
public static class Meta {
private String resourceType;
private LocalDateTime created;
private LocalDateTime lastModified;
private String location;
private String version;
// Getters and setters
public String getResourceType() { return resourceType; }
public void setResourceType(String resourceType) { this.resourceType = resourceType; }
public LocalDateTime getCreated() { return created; }
public void setCreated(LocalDateTime created) { this.created = created; }
public LocalDateTime getLastModified() { return lastModified; }
public void setLastModified(LocalDateTime lastModified) { this.lastModified = lastModified; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
}
}
SCIM Group Entity:
package com.myapp.scim.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "scim_groups", uniqueConstraints = {
@UniqueConstraint(columnNames = "displayName"),
@UniqueConstraint(columnNames = "externalId")
})
public class ScimGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String scimId;
private String externalId;
private String displayName;
private String description;
@ElementCollection
@CollectionTable(name = "scim_group_members", joinColumns = @JoinColumn(name = "group_id"))
private List<Member> members = new ArrayList<>();
// Metadata
private String resourceType = "Group";
private LocalDateTime created;
private LocalDateTime lastModified;
private String location;
private String version;
@Embedded
private Meta meta;
// Constructors
public ScimGroup() {
this.scimId = java.util.UUID.randomUUID().toString();
this.created = LocalDateTime.now();
this.lastModified = LocalDateTime.now();
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getScimId() { return scimId; }
public void setScimId(String scimId) { this.scimId = scimId; }
public String getExternalId() { return externalId; }
public void setExternalId(String externalId) { this.externalId = externalId; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public List<Member> getMembers() { return members; }
public void setMembers(List<Member> members) { this.members = members; }
public String getResourceType() { return resourceType; }
public void setResourceType(String resourceType) { this.resourceType = resourceType; }
public LocalDateTime getCreated() { return created; }
public void setCreated(LocalDateTime created) { this.created = created; }
public LocalDateTime getLastModified() { return lastModified; }
public void setLastModified(LocalDateTime lastModified) { this.lastModified = lastModified; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
public Meta getMeta() { return meta; }
public void setMeta(Meta meta) { this.meta = meta; }
@PreUpdate
public void preUpdate() {
this.lastModified = LocalDateTime.now();
this.version = java.util.UUID.randomUUID().toString();
}
@Embeddable
public static class Member {
private String value; // Member's SCIM id
private String ref; // Member's resource URL
private String displayName;
private String type = "User"; // User or Group
// Getters and setters
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getRef() { return ref; }
public void setRef(String ref) { this.ref = ref; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
}
@Embeddable
public static class Meta {
private String resourceType;
private LocalDateTime created;
private LocalDateTime lastModified;
private String location;
private String version;
// Getters and setters
public String getResourceType() { return resourceType; }
public void setResourceType(String resourceType) { this.resourceType = resourceType; }
public LocalDateTime getCreated() { return created; }
public void setCreated(LocalDateTime created) { this.created = created; }
public LocalDateTime getLastModified() { return lastModified; }
public void setLastModified(LocalDateTime lastModified) { this.lastModified = lastModified; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
}
}
5. SCIM Service Layer
SCIM User Service:
package com.myapp.scim.service;
import com.myapp.scim.model.ScimUser;
import com.myapp.scim.repository.ScimUserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class ScimUserService {
private static final Logger logger = LoggerFactory.getLogger(ScimUserService.class);
private final ScimUserRepository userRepository;
@Value("${scim.base-url:http://localhost:8080/scim/v2}")
private String scimBaseUrl;
public ScimUserService(ScimUserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional
public ScimUser createUser(ScimUser user) {
// Generate SCIM metadata
user.setScimId(UUID.randomUUID().toString());
user.setCreated(LocalDateTime.now());
user.setLastModified(LocalDateTime.now());
user.setVersion(UUID.randomUUID().toString());
user.setLocation(scimBaseUrl + "/Users/" + user.getScimId());
// Set meta information
ScimUser.Meta meta = new ScimUser.Meta();
meta.setResourceType("User");
meta.setCreated(user.getCreated());
meta.setLastModified(user.getLastModified());
meta.setLocation(user.getLocation());
meta.setVersion(user.getVersion());
user.setMeta(meta);
ScimUser savedUser = userRepository.save(user);
logger.info("Created SCIM user: {} with id: {}", savedUser.getUserName(), savedUser.getScimId());
return savedUser;
}
public Optional<ScimUser> getUserById(String scimId) {
return userRepository.findByScimId(scimId);
}
public Optional<ScimUser> getUserByUserName(String userName) {
return userRepository.findByUserName(userName);
}
public Optional<ScimUser> getUserByExternalId(String externalId) {
return userRepository.findByExternalId(externalId);
}
public Page<ScimUser> getUsers(Pageable pageable) {
return userRepository.findAll(pageable);
}
public Page<ScimUser> getUsers(Specification<ScimUser> spec, Pageable pageable) {
return userRepository.findAll(spec, pageable);
}
public List<ScimUser> getUsersByFilter(String filter) {
// Implement SCIM filter parsing and conversion to JPA Specification
// This is a simplified implementation
return userRepository.findAll();
}
@Transactional
public ScimUser updateUser(String scimId, ScimUser updatedUser) {
return userRepository.findByScimId(scimId).map(existingUser -> {
// Update fields
if (updatedUser.getUserName() != null) {
existingUser.setUserName(updatedUser.getUserName());
}
if (updatedUser.getExternalId() != null) {
existingUser.setExternalId(updatedUser.getExternalId());
}
if (updatedUser.getName() != null) {
existingUser.setName(updatedUser.getName());
}
if (updatedUser.getEmail() != null) {
existingUser.setEmail(updatedUser.getEmail());
}
if (updatedUser.getActive() != null) {
existingUser.setActive(updatedUser.getActive());
}
if (updatedUser.getEmails() != null) {
existingUser.setEmails(updatedUser.getEmails());
}
if (updatedUser.getPhoneNumbers() != null) {
existingUser.setPhoneNumbers(updatedUser.getPhoneNumbers());
}
if (updatedUser.getRoles() != null) {
existingUser.setRoles(updatedUser.getRoles());
}
// Update enterprise attributes if provided
if (updatedUser.getEmployeeNumber() != null) {
existingUser.setEmployeeNumber(updatedUser.getEmployeeNumber());
}
if (updatedUser.getDepartment() != null) {
existingUser.setDepartment(updatedUser.getDepartment());
}
if (updatedUser.getManager() != null) {
existingUser.setManager(updatedUser.getManager());
}
// Update metadata
existingUser.setLastModified(LocalDateTime.now());
existingUser.setVersion(UUID.randomUUID().toString());
if (existingUser.getMeta() != null) {
existingUser.getMeta().setLastModified(existingUser.getLastModified());
existingUser.getMeta().setVersion(existingUser.getVersion());
}
ScimUser savedUser = userRepository.save(existingUser);
logger.info("Updated SCIM user: {}", scimId);
return savedUser;
}).orElseThrow(() -> new UserNotFoundException("User not found: " + scimId));
}
@Transactional
public ScimUser patchUser(String scimId, ScimUser patchUser) {
// Implement SCIM PATCH operation
// This would handle partial updates using JSON Patch or specific SCIM patch operations
return updateUser(scimId, patchUser); // Simplified implementation
}
@Transactional
public void deleteUser(String scimId) {
userRepository.findByScimId(scimId).ifPresent(user -> {
userRepository.delete(user);
logger.info("Deleted SCIM user: {}", scimId);
});
}
@Transactional
public void deactivateUser(String scimId) {
userRepository.findByScimId(scimId).ifPresent(user -> {
user.setActive(false);
user.setLastModified(LocalDateTime.now());
user.setVersion(UUID.randomUUID().toString());
userRepository.save(user);
logger.info("Deactivated SCIM user: {}", scimId);
});
}
public boolean userExists(String scimId) {
return userRepository.findByScimId(scimId).isPresent();
}
public long getUserCount() {
return userRepository.count();
}
// Custom Exceptions
public static class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
}
public static class UserAlreadyExistsException extends RuntimeException {
public UserAlreadyExistsException(String message) {
super(message);
}
}
}
SCIM Group Service:
package com.myapp.scim.service;
import com.myapp.scim.model.ScimGroup;
import com.myapp.scim.repository.ScimGroupRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID;
@Service
public class ScimGroupService {
private static final Logger logger = LoggerFactory.getLogger(ScimGroupService.class);
private final ScimGroupRepository groupRepository;
private final ScimUserService userService;
@Value("${scim.base-url:http://localhost:8080/scim/v2}")
private String scimBaseUrl;
public ScimGroupService(ScimGroupRepository groupRepository, ScimUserService userService) {
this.groupRepository = groupRepository;
this.userService = userService;
}
@Transactional
public ScimGroup createGroup(ScimGroup group) {
// Generate SCIM metadata
group.setScimId(UUID.randomUUID().toString());
group.setCreated(LocalDateTime.now());
group.setLastModified(LocalDateTime.now());
group.setVersion(UUID.randomUUID().toString());
group.setLocation(scimBaseUrl + "/Groups/" + group.getScimId());
// Set meta information
ScimGroup.Meta meta = new ScimGroup.Meta();
meta.setResourceType("Group");
meta.setCreated(group.getCreated());
meta.setLastModified(group.getLastModified());
meta.setLocation(group.getLocation());
meta.setVersion(group.getVersion());
group.setMeta(meta);
// Validate members exist
validateGroupMembers(group);
ScimGroup savedGroup = groupRepository.save(group);
logger.info("Created SCIM group: {} with id: {}", savedGroup.getDisplayName(), savedGroup.getScimId());
return savedGroup;
}
public Optional<ScimGroup> getGroupById(String scimId) {
return groupRepository.findByScimId(scimId);
}
public Optional<ScimGroup> getGroupByDisplayName(String displayName) {
return groupRepository.findByDisplayName(displayName);
}
public Page<ScimGroup> getGroups(Pageable pageable) {
return groupRepository.findAll(pageable);
}
@Transactional
public ScimGroup updateGroup(String scimId, ScimGroup updatedGroup) {
return groupRepository.findByScimId(scimId).map(existingGroup -> {
// Update fields
if (updatedGroup.getDisplayName() != null) {
existingGroup.setDisplayName(updatedGroup.getDisplayName());
}
if (updatedGroup.getDescription() != null) {
existingGroup.setDescription(updatedGroup.getDescription());
}
if (updatedGroup.getMembers() != null) {
validateGroupMembers(updatedGroup);
existingGroup.setMembers(updatedGroup.getMembers());
}
// Update metadata
existingGroup.setLastModified(LocalDateTime.now());
existingGroup.setVersion(UUID.randomUUID().toString());
if (existingGroup.getMeta() != null) {
existingGroup.getMeta().setLastModified(existingGroup.getLastModified());
existingGroup.getMeta().setVersion(existingGroup.getVersion());
}
ScimGroup savedGroup = groupRepository.save(existingGroup);
logger.info("Updated SCIM group: {}", scimId);
return savedGroup;
}).orElseThrow(() -> new GroupNotFoundException("Group not found: " + scimId));
}
@Transactional
public void deleteGroup(String scimId) {
groupRepository.findByScimId(scimId).ifPresent(group -> {
groupRepository.delete(group);
logger.info("Deleted SCIM group: {}", scimId);
});
}
@Transactional
public ScimGroup addMemberToGroup(String groupScimId, ScimGroup.Member member) {
return groupRepository.findByScimId(groupScimId).map(group -> {
// Validate member exists
if ("User".equals(member.getType())) {
userService.getUserById(member.getValue())
.orElseThrow(() -> new IllegalArgumentException("User not found: " + member.getValue()));
}
group.getMembers().add(member);
group.setLastModified(LocalDateTime.now());
group.setVersion(UUID.randomUUID().toString());
return groupRepository.save(group);
}).orElseThrow(() -> new GroupNotFoundException("Group not found: " + groupScimId));
}
@Transactional
public ScimGroup removeMemberFromGroup(String groupScimId, String memberScimId) {
return groupRepository.findByScimId(groupScimId).map(group -> {
group.getMembers().removeIf(member -> memberScimId.equals(member.getValue()));
group.setLastModified(LocalDateTime.now());
group.setVersion(UUID.randomUUID().toString());
return groupRepository.save(group);
}).orElseThrow(() -> new GroupNotFoundException("Group not found: " + groupScimId));
}
private void validateGroupMembers(ScimGroup group) {
for (ScimGroup.Member member : group.getMembers()) {
if ("User".equals(member.getType())) {
userService.getUserById(member.getValue())
.orElseThrow(() -> new IllegalArgumentException("User not found: " + member.getValue()));
}
// Set member reference URL
member.setRef(scimBaseUrl + "/" +
("User".equals(member.getType()) ? "Users" : "Groups") + "/" + member.getValue());
}
}
// Custom Exceptions
public static class GroupNotFoundException extends RuntimeException {
public GroupNotFoundException(String message) {
super(message);
}
}
}
6. SCIM REST Controllers
SCIM User Controller:
package com.myapp.scim.controller;
import com.myapp.scim.model.ScimUser;
import com.myapp.scim.service.ScimUserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/Users")
public class ScimUserController {
private static final Logger logger = LoggerFactory.getLogger(ScimUserController.class);
private final ScimUserService userService;
public ScimUserController(ScimUserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<?> createUser(@RequestBody ScimUser user) {
try {
// Check if user already exists
if (user.getUserName() != null &&
userService.getUserByUserName(user.getUserName()).isPresent()) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ScimErrorResponse.conflict("User already exists: " + user.getUserName()));
}
ScimUser createdUser = userService.createUser(user);
return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
} catch (Exception e) {
logger.error("Failed to create user", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ScimErrorResponse.internalError(e.getMessage()));
}
}
@GetMapping("/{id}")
public ResponseEntity<?> getUser(@PathVariable String id) {
try {
Optional<ScimUser> user = userService.getUserById(id);
if (user.isPresent()) {
return ResponseEntity.ok(user.get());
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ScimErrorResponse.notFound("User not found: " + id));
}
} catch (Exception e) {
logger.error("Failed to get user: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ScimErrorResponse.internalError(e.getMessage()));
}
}
@GetMapping
public ResponseEntity<?> getUsers(
@RequestParam(defaultValue = "1") int startIndex,
@RequestParam(defaultValue = "100") int count,
@RequestParam(required = false) String filter) {
try {
Pageable pageable = PageRequest.of((startIndex - 1) / count, count);
Page<ScimUser> usersPage;
if (filter != null && !filter.trim().isEmpty()) {
// Apply SCIM filter
List<ScimUser> filteredUsers = userService.getUsersByFilter(filter);
usersPage = Page.empty(pageable); // Simplified - implement proper pagination with filters
} else {
usersPage = userService.getUsers(pageable);
}
ScimListResponse<ScimUser> response = new ScimListResponse<>(
usersPage.getContent(),
usersPage.getTotalElements(),
startIndex,
count
);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Failed to get users", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ScimErrorResponse.internalError(e.getMessage()));
}
}
@PutMapping("/{id}")
public ResponseEntity<?> updateUser(@PathVariable String id, @RequestBody ScimUser user) {
try {
ScimUser updatedUser = userService.updateUser(id, user);
return ResponseEntity.ok(updatedUser);
} catch (ScimUserService.UserNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ScimErrorResponse.notFound(e.getMessage()));
} catch (Exception e) {
logger.error("Failed to update user: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ScimErrorResponse.internalError(e.getMessage()));
}
}
@PatchMapping("/{id}")
public ResponseEntity<?> patchUser(@PathVariable String id, @RequestBody ScimUser user) {
try {
ScimUser patchedUser = userService.patchUser(id, user);
return ResponseEntity.ok(patchedUser);
} catch (ScimUserService.UserNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ScimErrorResponse.notFound(e.getMessage()));
} catch (Exception e) {
logger.error("Failed to patch user: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ScimErrorResponse.internalError(e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable String id) {
try {
if (!userService.userExists(id)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ScimErrorResponse.notFound("User not found: " + id));
}
userService.deleteUser(id);
return ResponseEntity.noContent().build();
} catch (Exception e) {
logger.error("Failed to delete user: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ScimErrorResponse.internalError(e.getMessage()));
}
}
// SCIM Response Wrappers
public static class ScimListResponse<T> {
private final List<T> resources;
private final long totalResults;
private final int startIndex;
private final int itemsPerPage;
private final List<String> schemas = List.of("urn:ietf:params:scim:api:messages:2.0:ListResponse");
public ScimListResponse(List<T> resources, long totalResults, int startIndex, int itemsPerPage) {
this.resources = resources;
this.totalResults = totalResults;
this.startIndex = startIndex;
this.itemsPerPage = itemsPerPage;
}
// Getters
public List<T> getResources() { return resources; }
public long getTotalResults() { return totalResults; }
public int getStartIndex() { return startIndex; }
public int getItemsPerPage() { return itemsPerPage; }
public List<String> getSchemas() { return schemas; }
}
public static class ScimErrorResponse {
private final List<String> schemas = List.of("urn:ietf:params:scim:api:messages:2.0:Error");
private final String scimType;
private final String detail;
private final String status;
public ScimErrorResponse(String scimType, String detail, String status) {
this.scimType = scimType;
this.detail = detail;
this.status = status;
}
public static ScimErrorResponse notFound(String detail) {
return new ScimErrorResponse("invalidValue", detail, "404");
}
public static ScimErrorResponse conflict(String detail) {
return new ScimErrorResponse("uniqueness", detail, "409");
}
public static ScimErrorResponse internalError(String detail) {
return new ScimErrorResponse("internalError", detail, "500");
}
// Getters
public List<String> getSchemas() { return schemas; }
public String getScimType() { return scimType; }
public String getDetail() { return detail; }
public String getStatus() { return status; }
}
}
SCIM Group Controller:
package com.myapp.scim.controller;
import com.myapp.scim.model.ScimGroup;
import com.myapp.scim.service.ScimGroupService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Optional;
@RestController
@RequestMapping("/Groups")
public class ScimGroupController {
private static final Logger logger = LoggerFactory.getLogger(ScimGroupController.class);
private final ScimGroupService groupService;
public ScimGroupController(ScimGroupService groupService) {
this.groupService = groupService;
}
@PostMapping
public ResponseEntity<?> createGroup(@RequestBody ScimGroup group) {
try {
ScimGroup createdGroup = groupService.createGroup(group);
return ResponseEntity.status(HttpStatus.CREATED).body(createdGroup);
} catch (Exception e) {
logger.error("Failed to create group", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ScimUserController.ScimErrorResponse.internalError(e.getMessage()));
}
}
@GetMapping("/{id}")
public ResponseEntity<?> getGroup(@PathVariable String id) {
try {
Optional<ScimGroup> group = groupService.getGroupById(id);
if (group.isPresent()) {
return ResponseEntity.ok(group.get());
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ScimUserController.ScimErrorResponse.notFound("Group not found: " + id));
}
} catch (Exception e) {
logger.error("Failed to get group: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ScimUserController.ScimErrorResponse.internalError(e.getMessage()));
}
}
@GetMapping
public ResponseEntity<?> getGroups(
@RequestParam(defaultValue = "1") int startIndex,
@RequestParam(defaultValue = "100") int count) {
try {
Pageable pageable = PageRequest.of((startIndex - 1) / count, count);
Page<ScimGroup> groupsPage = groupService.getGroups(pageable);
ScimUserController.ScimListResponse<ScimGroup> response =
new ScimUserController.ScimListResponse<>(
groupsPage.getContent(),
groupsPage.getTotalElements(),
startIndex,
count
);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Failed to get groups", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ScimUserController.ScimErrorResponse.internalError(e.getMessage()));
}
}
@PutMapping("/{id}")
public ResponseEntity<?> updateGroup(@PathVariable String id, @RequestBody ScimGroup group) {
try {
ScimGroup updatedGroup = groupService.updateGroup(id, group);
return ResponseEntity.ok(updatedGroup);
} catch (ScimGroupService.GroupNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ScimUserController.ScimErrorResponse.notFound(e.getMessage()));
} catch (Exception e) {
logger.error("Failed to update group: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ScimUserController.ScimErrorResponse.internalError(e.getMessage()));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteGroup(@PathVariable String id) {
try {
if (!groupService.getGroupById(id).isPresent()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ScimUserController.ScimErrorResponse.notFound("Group not found: " + id));
}
groupService.deleteGroup(id);
return ResponseEntity.noContent().build();
} catch (Exception e) {
logger.error("Failed to delete group: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ScimUserController.ScimErrorResponse.internalError(e.getMessage()));
}
}
}
7. SCIM Service Provider Configuration
Service Provider Config Controller:
package com.myapp.scim.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/ServiceProviderConfig")
public class ServiceProviderConfigController {
@GetMapping
public ResponseEntity<Map<String, Object>> getServiceProviderConfig() {
Map<String, Object> config = Map.of(
"schemas", List.of("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"),
"documentationUri", "https://docs.mycompany.com/scim",
"patch", Map.of("supported", true),
"bulk", Map.of(
"supported", true,
"maxOperations", 1000,
"maxPayloadSize", 1048576
),
"filter", Map.of(
"supported", true,
"maxResults", 1000
),
"changePassword", Map.of("supported", false),
"sort", Map.of("supported", true),
"etag", Map.of("supported", true),
"authenticationSchemes", List.of(
Map.of(
"name", "OAuth Bearer Token",
"description", "Authentication scheme using the OAuth Bearer Token Standard",
"specUri", "https://tools.ietf.org/html/rfc6750",
"type", "oauthbearertoken",
"primary", true
)
)
);
return ResponseEntity.ok(config);
}
}
Resource Types Controller:
package com.myapp.scim.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/ResourceTypes")
public class ResourceTypesController {
@GetMapping
public ResponseEntity<List<Map<String, Object>>> getResourceTypes() {
List<Map<String, Object>> resourceTypes = List.of(
Map.of(
"schemas", List.of("urn:ietf:params:scim:schemas:core:2.0:ResourceType"),
"id", "User",
"name", "User",
"endpoint", "/Users",
"description", "User Account",
"schema", "urn:ietf:params:scim:schemas:core:2.0:User",
"schemaExtensions", List.of(
Map.of(
"schema", "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
"required", false
)
)
),
Map.of(
"schemas", List.of("urn:ietf:params:scim:schemas:core:2.0:ResourceType"),
"id", "Group",
"name", "Group",
"endpoint", "/Groups",
"description", "Group",
"schema", "urn:ietf:params:scim:schemas:core:2.0:Group"
)
);
return ResponseEntity.ok(resourceTypes);
}
}
8. Security Configuration
SCIM Security Configuration:
package com.myapp.scim.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${scim.auth.bearer-token:scim-token-123}")
private String bearerToken;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/ServiceProviderConfig", "/ResourceTypes", "/Schemas").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new ScimBearerTokenFilter(bearerToken), BasicAuthenticationFilter.class);
return http;
}
}
Bearer Token Filter:
package com.myapp.scim.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
public class ScimBearerTokenFilter extends OncePerRequestFilter {
private final String expectedToken;
public ScimBearerTokenFilter(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)) {
// Token is valid, set authentication
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken("scim-client", null,
List.of(new SimpleGrantedAuthority("ROLE_SCIM_CLIENT")));
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid bearer token");
return;
}
} else {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing bearer token");
return;
}
filterChain.doFilter(request, response);
}
}
9. Repository Layer
SCIM User Repository:
package com.myapp.scim.repository;
import com.myapp.scim.model.ScimUser;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface ScimUserRepository extends JpaRepository<ScimUser, Long>, JpaSpecificationExecutor<ScimUser> {
Optional<ScimUser> findByScimId(String scimId);
Optional<ScimUser> findByUserName(String userName);
Optional<ScimUser> findByExternalId(String externalId);
boolean existsByUserName(String userName);
long countByActiveTrue();
}
SCIM Group Repository:
package com.myapp.scim.repository;
import com.myapp.scim.model.ScimGroup;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface ScimGroupRepository extends JpaRepository<ScimGroup, Long> {
Optional<ScimGroup> findByScimId(String scimId);
Optional<ScimGroup> findByDisplayName(String displayName);
Optional<ScimGroup> findByExternalId(String externalId);
}
10. Benefits of SCIM Provisioning
- Standardization - Industry-standard protocol for identity management
- Automation - Automated user provisioning and deprovisioning
- Interoperability - Works with major identity providers
- Security - Reduced manual errors in user management
- Compliance - Audit trail for user lifecycle events
- Efficiency - Reduced IT overhead for user management
Conclusion
Implementing SCIM 2.0 provisioning in Java provides a standardized, secure, and automated way to manage user identities across systems. By leveraging Spring Boot and following SCIM specifications, you can create a robust identity provisioning system that integrates seamlessly with enterprise identity providers.
The key to successful SCIM implementation is:
- Proper schema adherence to SCIM 2.0 specifications
- Robust error handling with SCIM-standard error responses
- Secure authentication using bearer tokens or OAuth2
- Comprehensive testing with various identity providers
- Proper user and group lifecycle management
- Performance optimization for bulk operations
Start with basic user provisioning and gradually add features like group management, bulk operations, and enterprise extensions as your requirements evolve.
Call to Action: Begin by implementing the basic SCIM user endpoints and test with a simple identity provider. Gradually add group management, filtering, and bulk operations. Ensure proper error handling and security measures are in place before moving to production.